feat: add Gateway HTTP server with Config API (Step 1)
Add pkg/gateway package implementing Config API endpoints: - GET /api/config/schema — dynamic schema via reflection - GET /api/config — config with secret masking - PUT /api/config — save + restart trigger - Bearer token auth middleware (skip when api_key empty) Add GatewayConfig.APIKey field and RLock/RUnlock methods to Config. Integrate gateway server into cmd/clawdroid with config-triggered process restart via syscall.Exec. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7e0856c907
commit
7e73df9752
9 changed files with 3144 additions and 5 deletions
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/agent"
|
"github.com/KarakuriAgent/clawdroid/pkg/agent"
|
||||||
|
|
@ -25,6 +26,7 @@ import (
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/channels"
|
"github.com/KarakuriAgent/clawdroid/pkg/channels"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/cron"
|
"github.com/KarakuriAgent/clawdroid/pkg/cron"
|
||||||
|
"github.com/KarakuriAgent/clawdroid/pkg/gateway"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/heartbeat"
|
"github.com/KarakuriAgent/clawdroid/pkg/heartbeat"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/logger"
|
"github.com/KarakuriAgent/clawdroid/pkg/logger"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/providers"
|
"github.com/KarakuriAgent/clawdroid/pkg/providers"
|
||||||
|
|
@ -442,6 +444,7 @@ func gatewayCmd() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configPath := getConfigPath()
|
||||||
cfg, err := loadConfig()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
|
|
@ -475,6 +478,22 @@ func gatewayCmd() {
|
||||||
"skills_available": skillsInfo["available"],
|
"skills_available": skillsInfo["available"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Restart channel for config-triggered restarts
|
||||||
|
restartCh := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
// Start Gateway HTTP server (Config API)
|
||||||
|
gwServer := gateway.NewServer(cfg, configPath, func() {
|
||||||
|
select {
|
||||||
|
case restartCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := gwServer.Start(); err != nil {
|
||||||
|
fmt.Printf("Error starting gateway HTTP server: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ Config API started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
|
||||||
// Setup cron tool and service
|
// Setup cron tool and service
|
||||||
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled)
|
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled)
|
||||||
|
|
||||||
|
|
@ -544,15 +563,35 @@ func gatewayCmd() {
|
||||||
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, os.Interrupt)
|
signal.Notify(sigChan, os.Interrupt)
|
||||||
<-sigChan
|
|
||||||
|
|
||||||
|
restart := false
|
||||||
|
select {
|
||||||
|
case <-sigChan:
|
||||||
|
case <-restartCh:
|
||||||
|
restart = true
|
||||||
|
fmt.Println("\nRestarting due to config change...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
fmt.Println("\nShutting down...")
|
fmt.Println("\nShutting down...")
|
||||||
cancel()
|
cancel()
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
gwServer.Stop(shutdownCtx)
|
||||||
heartbeatService.Stop()
|
heartbeatService.Stop()
|
||||||
cronService.Stop()
|
cronService.Stop()
|
||||||
agentLoop.Stop()
|
agentLoop.Stop()
|
||||||
channelManager.StopAll(ctx)
|
channelManager.StopAll(shutdownCtx)
|
||||||
fmt.Println("✓ Gateway stopped")
|
fmt.Println("✓ Gateway stopped")
|
||||||
|
|
||||||
|
if restart {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error finding executable: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
syscall.Exec(exe, os.Args, os.Environ())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusCmd() {
|
func statusCmd() {
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,8 @@
|
||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 18790
|
"port": 18790,
|
||||||
|
"api_key": ""
|
||||||
},
|
},
|
||||||
"rate_limits": {
|
"rate_limits": {
|
||||||
"max_tool_calls_per_minute": 30,
|
"max_tool_calls_per_minute": 30,
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,7 @@ type RateLimitsConfig struct {
|
||||||
type GatewayConfig struct {
|
type GatewayConfig struct {
|
||||||
Host string `json:"host" env:"CLAWDROID_GATEWAY_HOST"`
|
Host string `json:"host" env:"CLAWDROID_GATEWAY_HOST"`
|
||||||
Port int `json:"port" env:"CLAWDROID_GATEWAY_PORT"`
|
Port int `json:"port" env:"CLAWDROID_GATEWAY_PORT"`
|
||||||
|
APIKey string `json:"api_key" env:"CLAWDROID_GATEWAY_API_KEY"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
|
|
@ -323,6 +324,9 @@ func SaveConfig(path string, cfg *Config) error {
|
||||||
return os.WriteFile(path, data, 0600)
|
return os.WriteFile(path, data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) RLock() { c.mu.RLock() }
|
||||||
|
func (c *Config) RUnlock() { c.mu.RUnlock() }
|
||||||
|
|
||||||
func (c *Config) WorkspacePath() string {
|
func (c *Config) WorkspacePath() string {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,14 @@ func TestConfig_DataPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultConfig_GatewayAPIKey verifies Gateway APIKey is empty by default
|
||||||
|
func TestDefaultConfig_GatewayAPIKey(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if cfg.Gateway.APIKey != "" {
|
||||||
|
t.Error("Gateway APIKey should be empty by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConfig_Complete verifies all config fields are set
|
// TestConfig_Complete verifies all config fields are set
|
||||||
func TestConfig_Complete(t *testing.T) {
|
func TestConfig_Complete(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
|
||||||
45
pkg/gateway/auth.go
Normal file
45
pkg/gateway/auth.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authMiddleware wraps a handler with Bearer token authentication.
|
||||||
|
// If cfg.Gateway.APIKey is empty, authentication is skipped.
|
||||||
|
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
apiKey := s.cfg.Gateway.APIKey
|
||||||
|
if apiKey == "" {
|
||||||
|
next(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "missing Authorization header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if !strings.HasPrefix(authHeader, prefix) {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "invalid Authorization format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := authHeader[len(prefix):]
|
||||||
|
if token != apiKey {
|
||||||
|
writeJSONError(w, http.StatusForbidden, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONError(w http.ResponseWriter, code int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||||
|
}
|
||||||
148
pkg/gateway/handlers.go
Normal file
148
pkg/gateway/handlers.go
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGetSchema returns the configuration schema.
|
||||||
|
func (s *Server) handleGetSchema(w http.ResponseWriter, r *http.Request) {
|
||||||
|
schema := BuildSchema(config.DefaultConfig())
|
||||||
|
writeJSON(w, http.StatusOK, schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetConfig returns the current configuration with secrets masked.
|
||||||
|
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.cfg.RLock()
|
||||||
|
data, err := json.Marshal(s.cfg)
|
||||||
|
s.cfg.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to marshal config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to process config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maskSecrets(raw)
|
||||||
|
writeJSON(w, http.StatusOK, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePutConfig updates the configuration.
|
||||||
|
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Read current config as a map for secret preservation
|
||||||
|
s.cfg.RLock()
|
||||||
|
currentData, err := json.Marshal(s.cfg)
|
||||||
|
s.cfg.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to read current config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(currentData, ¤tMap); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to process current config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode request body into a map to inspect raw values
|
||||||
|
var incoming map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&incoming); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore masked secrets: replace "****" with current values
|
||||||
|
restoreSecrets(incoming, currentMap)
|
||||||
|
|
||||||
|
// Marshal merged map, then unmarshal onto a deep copy of current config (partial update)
|
||||||
|
mergedData, err := json.Marshal(incoming)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to prepare config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep copy current config via JSON round-trip to avoid shared map/slice references
|
||||||
|
var newCfg config.Config
|
||||||
|
if err := json.Unmarshal(currentData, &newCfg); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to copy config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(mergedData, &newCfg); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid config: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(s.configPath, &newCfg); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to save config: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"restart": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger restart after response is sent
|
||||||
|
if s.onRestart != nil {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
s.onRestart()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskSecrets replaces non-empty secret values with "****".
|
||||||
|
func maskSecrets(m map[string]interface{}) {
|
||||||
|
for k, v := range m {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
maskSecrets(val)
|
||||||
|
case string:
|
||||||
|
if secretKeys[k] && val != "" {
|
||||||
|
m[k] = "****"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreSecrets replaces "****" values in incoming with the corresponding current values.
|
||||||
|
func restoreSecrets(incoming, current map[string]interface{}) {
|
||||||
|
for k, v := range incoming {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
if curSub, ok := current[k].(map[string]interface{}); ok {
|
||||||
|
restoreSecrets(val, curSub)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if secretKeys[k] && val == "****" {
|
||||||
|
if curVal, ok := current[k]; ok {
|
||||||
|
incoming[k] = curVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSecretKey returns true if the given JSON key should be treated as a secret.
|
||||||
|
// This is used by the handlers to check whether to mask a value, and is also
|
||||||
|
// exposed for testing purposes.
|
||||||
|
func isSecretKey(key string) bool {
|
||||||
|
// Check the leaf key (last segment after dots)
|
||||||
|
parts := strings.SplitN(key, ".", -1)
|
||||||
|
leaf := parts[len(parts)-1]
|
||||||
|
return secretKeys[leaf]
|
||||||
|
}
|
||||||
196
pkg/gateway/schema.go
Normal file
196
pkg/gateway/schema.go
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SchemaField describes a single configuration field.
|
||||||
|
type SchemaField struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Secret bool `json:"secret"`
|
||||||
|
Default interface{} `json:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaSection describes a top-level configuration section.
|
||||||
|
type SchemaSection struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Fields []SchemaField `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaResponse is the top-level schema response.
|
||||||
|
type SchemaResponse struct {
|
||||||
|
Sections []SchemaSection `json:"sections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// acronyms maps lowercase abbreviations to their uppercase forms for label generation.
|
||||||
|
var acronyms = map[string]string{
|
||||||
|
"api": "API", "llm": "LLM", "url": "URL", "ws": "WS",
|
||||||
|
"id": "ID", "mcp": "MCP",
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretKeys lists JSON keys that contain sensitive values.
|
||||||
|
var secretKeys = map[string]bool{
|
||||||
|
"api_key": true,
|
||||||
|
"token": true,
|
||||||
|
"bot_token": true,
|
||||||
|
"app_token": true,
|
||||||
|
"channel_secret": true,
|
||||||
|
"channel_access_token": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSchema generates a SchemaResponse by reflecting over a default Config.
|
||||||
|
func BuildSchema(defaultCfg *config.Config) SchemaResponse {
|
||||||
|
var sections []SchemaSection
|
||||||
|
|
||||||
|
cfgType := reflect.TypeOf(*defaultCfg)
|
||||||
|
cfgVal := reflect.ValueOf(*defaultCfg)
|
||||||
|
|
||||||
|
for i := 0; i < cfgType.NumField(); i++ {
|
||||||
|
field := cfgType.Field(i)
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonTag := jsonKey(field)
|
||||||
|
if jsonTag == "" || jsonTag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
section := SchemaSection{
|
||||||
|
Key: jsonTag,
|
||||||
|
Label: snakeToTitle(jsonTag),
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldVal := cfgVal.Field(i)
|
||||||
|
section.Fields = buildFields(field.Type, fieldVal, "")
|
||||||
|
sections = append(sections, section)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SchemaResponse{Sections: sections}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFields recursively collects fields from a struct type, flattening nested structs
|
||||||
|
// with dot-separated key prefixes.
|
||||||
|
func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField {
|
||||||
|
var fields []SchemaField
|
||||||
|
|
||||||
|
if t.Kind() == reflect.Ptr {
|
||||||
|
t = t.Elem()
|
||||||
|
if v.IsValid() && !v.IsNil() {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Kind() != reflect.Struct {
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
sf := t.Field(i)
|
||||||
|
if !sf.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jk := jsonKey(sf)
|
||||||
|
if jk == "" || jk == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fullKey := jk
|
||||||
|
if prefix != "" {
|
||||||
|
fullKey = prefix + "." + jk
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldVal := v.Field(i)
|
||||||
|
ft := sf.Type
|
||||||
|
|
||||||
|
// Dereference pointer types
|
||||||
|
if ft.Kind() == reflect.Ptr {
|
||||||
|
ft = ft.Elem()
|
||||||
|
if fieldVal.IsValid() && !fieldVal.IsNil() {
|
||||||
|
fieldVal = fieldVal.Elem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaType := goTypeToSchema(ft)
|
||||||
|
if schemaType == "object" {
|
||||||
|
// Nested struct: recurse and flatten
|
||||||
|
fields = append(fields, buildFields(ft, fieldVal, fullKey)...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var defVal interface{}
|
||||||
|
if fieldVal.IsValid() {
|
||||||
|
defVal = fieldVal.Interface()
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = append(fields, SchemaField{
|
||||||
|
Key: fullKey,
|
||||||
|
Label: snakeToTitle(jk),
|
||||||
|
Type: schemaType,
|
||||||
|
Secret: secretKeys[jk],
|
||||||
|
Default: defVal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// goTypeToSchema maps a Go reflect.Type to a schema type string.
|
||||||
|
func goTypeToSchema(t reflect.Type) string {
|
||||||
|
// Check for FlexibleStringSlice by name
|
||||||
|
if t.Name() == "FlexibleStringSlice" {
|
||||||
|
return "[]string"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch t.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
return "string"
|
||||||
|
case reflect.Bool:
|
||||||
|
return "bool"
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return "int"
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return "float"
|
||||||
|
case reflect.Slice:
|
||||||
|
if t.Elem().Kind() == reflect.String {
|
||||||
|
return "[]string"
|
||||||
|
}
|
||||||
|
return "[]any"
|
||||||
|
case reflect.Map:
|
||||||
|
return "map"
|
||||||
|
case reflect.Struct:
|
||||||
|
return "object"
|
||||||
|
default:
|
||||||
|
return "any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonKey extracts the JSON field name from a struct field's tag.
|
||||||
|
func jsonKey(f reflect.StructField) string {
|
||||||
|
tag := f.Tag.Get("json")
|
||||||
|
if tag == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(tag, ",", 2)
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// snakeToTitle converts a snake_case string to Title Case, applying acronym rules.
|
||||||
|
func snakeToTitle(s string) string {
|
||||||
|
parts := strings.Split(s, "_")
|
||||||
|
for i, p := range parts {
|
||||||
|
if upper, ok := acronyms[strings.ToLower(p)]; ok {
|
||||||
|
parts[i] = upper
|
||||||
|
} else if len(p) > 0 {
|
||||||
|
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
58
pkg/gateway/server.go
Normal file
58
pkg/gateway/server.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
||||||
|
"github.com/KarakuriAgent/clawdroid/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server is the Gateway HTTP server that exposes the Config API.
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
configPath string
|
||||||
|
server *http.Server
|
||||||
|
onRestart func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new Gateway HTTP server.
|
||||||
|
func NewServer(cfg *config.Config, configPath string, onRestart func()) *Server {
|
||||||
|
return &Server{
|
||||||
|
cfg: cfg,
|
||||||
|
configPath: configPath,
|
||||||
|
onRestart: onRestart,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins listening for HTTP requests on the configured host:port.
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /api/config/schema", s.authMiddleware(s.handleGetSchema))
|
||||||
|
mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig))
|
||||||
|
mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig))
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port)
|
||||||
|
s.server = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
logger.InfoCF("gateway", "HTTP server starting", map[string]interface{}{"addr": addr})
|
||||||
|
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.ErrorCF("gateway", "HTTP server error", map[string]interface{}{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully shuts down the HTTP server.
|
||||||
|
func (s *Server) Stop(ctx context.Context) error {
|
||||||
|
if s.server == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.server.Shutdown(ctx)
|
||||||
|
}
|
||||||
2640
pkg/gateway/server_test.go
Normal file
2640
pkg/gateway/server_test.go
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue