feat(api): GET/PUT /api/config serving app + env configuration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,10 @@ func main() {
|
|||||||
FrontendURL: cfg.FrontendURL,
|
FrontendURL: cfg.FrontendURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for _, e := range cfg.DisplayEnv() {
|
||||||
|
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
|
||||||
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
|||||||
88
backend/internal/api/appconfig.go
Normal file
88
backend/internal/api/appconfig.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnvVar is one read-only environment-configuration entry, already
|
||||||
|
// display-safe (masking happens in config.Config.DisplayEnv).
|
||||||
|
type EnvVar struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type appConfigEntry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
Overridden bool `json:"overridden"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// configView assembles the GET/PUT response: registry defaults overlaid
|
||||||
|
// with DB overrides, plus the env snapshot.
|
||||||
|
func (s *Server) configView(r *http.Request) (map[string]any, error) {
|
||||||
|
overrides, err := s.DB.ConfigValues(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
registry := config.AppRegistry()
|
||||||
|
app := make([]appConfigEntry, 0, len(registry))
|
||||||
|
for _, k := range registry {
|
||||||
|
value, overridden := overrides[k.Key]
|
||||||
|
if !overridden {
|
||||||
|
value = k.Default
|
||||||
|
}
|
||||||
|
app = append(app, appConfigEntry{
|
||||||
|
Key: k.Key, Value: value, Default: k.Default,
|
||||||
|
Overridden: overridden, Description: k.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
envVars := s.EnvVars
|
||||||
|
if envVars == nil {
|
||||||
|
envVars = []EnvVar{}
|
||||||
|
}
|
||||||
|
return map[string]any{"application": app, "environment": envVars}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp, err := s.configView(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePutConfig updates application configuration. Cold: saved values
|
||||||
|
// take effect on the next backend restart; the response is just the
|
||||||
|
// refreshed view, same shape as GET.
|
||||||
|
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Validate every pair before writing anything -- all-or-nothing.
|
||||||
|
for key, value := range body {
|
||||||
|
if err := config.ValidateAppValue(key, value); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, value := range body {
|
||||||
|
if err := s.DB.SetConfigValue(r.Context(), key, value); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := s.configView(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
99
backend/internal/api/appconfig_test.go
Normal file
99
backend/internal/api/appconfig_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/garmin"
|
||||||
|
"geniusrun/backend/internal/garmin/mock"
|
||||||
|
"geniusrun/backend/internal/store"
|
||||||
|
appsync "geniusrun/backend/internal/sync"
|
||||||
|
|
||||||
|
authmock "geniusrun/backend/internal/auth/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type configTestResponse struct {
|
||||||
|
Application []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
Overridden bool `json:"overridden"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"application"`
|
||||||
|
Environment []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"environment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) {
|
||||||
|
s, _, _ := newTestServer(t)
|
||||||
|
s.EnvVars = []EnvVar{{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: "•••• (set)"}}
|
||||||
|
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp configTestResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Application) != 1 {
|
||||||
|
t.Fatalf("expected 1 app-config entry, got %d", len(resp.Application))
|
||||||
|
}
|
||||||
|
e := resp.Application[0]
|
||||||
|
if e.Key != "session.duration" || e.Value != "720" || e.Default != "720" || e.Overridden || e.Description == "" {
|
||||||
|
t.Fatalf("unexpected default entry: %+v", e)
|
||||||
|
}
|
||||||
|
if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" {
|
||||||
|
t.Fatalf("env snapshot not passed through: %+v", resp.Environment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_PutPersistsValidatesAllOrNothing(t *testing.T) {
|
||||||
|
s, _, _ := newTestServer(t)
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
|
rec := doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "168"})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("valid PUT status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp configTestResponse
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Application[0].Value != "168" || !resp.Application[0].Overridden {
|
||||||
|
t.Fatalf("override not reflected: %+v", resp.Application[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"bogus.key": "1"}); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("unknown key status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "zero"})
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid value status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/config", nil)
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Application[0].Value != "168" {
|
||||||
|
t.Fatalf("rejected PUT still changed the value: %+v", resp.Application[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same gate as every data route: an unprovisioned session gets 403, per
|
||||||
|
// the repo's adversarial-isolation testing convention.
|
||||||
|
func TestConfig_RequiresProvisionedUser(t *testing.T) {
|
||||||
|
db, err := store.Open(t.TempDir() + "/config_gate_test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
m := &mock.Client{}
|
||||||
|
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
|
||||||
|
if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("GET status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := doJSON(t, s.Router(), http.MethodPut, "/api/config", map[string]string{"session.duration": "1"}); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("PUT status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,12 @@ type Server struct {
|
|||||||
GarminBase garmin.Config
|
GarminBase garmin.Config
|
||||||
SyncConfig appsync.Config
|
SyncConfig appsync.Config
|
||||||
|
|
||||||
|
// EnvVars is the read-only, display-safe environment-configuration
|
||||||
|
// snapshot served by GET /api/config -- built once in main.go from
|
||||||
|
// config.Config.DisplayEnv() (secrets already masked there); handlers
|
||||||
|
// never call os.Getenv.
|
||||||
|
EnvVars []EnvVar
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
userGarmin map[int64]garmin.Client
|
userGarmin map[int64]garmin.Client
|
||||||
userSync map[int64]*appsync.Service
|
userSync map[int64]*appsync.Service
|
||||||
@@ -363,6 +369,9 @@ func (s *Server) Router() http.Handler {
|
|||||||
|
|
||||||
r.Post("/reclassify", s.handleReclassifyAll)
|
r.Post("/reclassify", s.handleReclassifyAll)
|
||||||
|
|
||||||
|
r.Get("/config", s.handleGetConfig)
|
||||||
|
r.Put("/config", s.handlePutConfig)
|
||||||
|
|
||||||
r.Get("/progression/{kindID}", s.handleProgression)
|
r.Get("/progression/{kindID}", s.handleProgression)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user