feat(api): unified tunable settings endpoints with typed validation
Add a single pair of admin-only endpoints that supersede the scattered
scan-settings JSON routes as the canonical way to read and write
tunable system settings. Existing legacy routes are kept working for
backward compatibility and now refresh the registry cache on write.
system_settings.go:
- GET /api/system/settings returns every known setting with full
metadata (value, type, min, max, requires_restart, category, group,
description, is_default) via SettingsRegistry.All().
- PUT /api/system/settings accepts {key, value}; ApplySetting() looks
up the compiled Default for the key, runs type-aware validation
(int range, bool parse, non-empty string, timezone via
time.LoadLocation), upserts via UpsertSystemSetting, reloads the
registry, and reports whether a restart is needed for the change to
take full effect. Shared by the JSON endpoint and the HTMX endpoint.
- Legacy UpdateScanSettings / GetScanSettings / UpdateTimezoneSettings
now reload the registry after writing and prefer the registry when
reading, so the cache stays consistent regardless of entry point.
sidecar.go:
- SidecarHandler gains an optional registry; the timezone branch of
UpdateSystemConfiguration (PUT /api/system/config) calls
settings.Reload() after the write so the new value is visible
immediately. base_url handling is unchanged.
system.go:
- Register GET/PUT /api/system/settings under the existing admin
/api/system group.
This commit is contained in:
@@ -15,14 +15,19 @@ import (
|
||||
)
|
||||
|
||||
type SidecarHandler struct {
|
||||
db *database.Queries
|
||||
cfg *config.Config
|
||||
db *database.Queries
|
||||
cfg *config.Config
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
|
||||
return &SidecarHandler{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
// SetSettings wires the tunable settings registry so the timezone write path
|
||||
// keeps the cache consistent.
|
||||
func (h *SidecarHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||
|
||||
type SidecarConfig struct {
|
||||
Version string `json:"version"`
|
||||
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
|
||||
@@ -400,6 +405,9 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
||||
"error": "failed to update default timezone",
|
||||
})
|
||||
}
|
||||
if h.settings != nil {
|
||||
h.settings.Reload(ctx)
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
||||
|
||||
@@ -2,17 +2,21 @@ package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type SystemSettingsHandler struct {
|
||||
db *database.Queries
|
||||
db *database.Queries
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
||||
@@ -21,6 +25,154 @@ func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// SetSettings wires the tunable settings registry. Required for the unified
|
||||
// /api/system/settings endpoints and for cache invalidation after writes.
|
||||
func (h *SystemSettingsHandler) SetSettings(s *database.SettingsRegistry) {
|
||||
h.settings = s
|
||||
}
|
||||
|
||||
// reload refreshes the in-memory cache after a write.
|
||||
func (h *SystemSettingsHandler) reload(c *echo.Context) {
|
||||
if h.settings != nil {
|
||||
h.settings.Reload(c.Request().Context())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Unified /api/system/settings endpoints ----
|
||||
|
||||
// GetSettings handles GET /api/system/settings.
|
||||
func (h *SystemSettingsHandler) GetSettings(c *echo.Context) error {
|
||||
if h.settings == nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, h.settings.All())
|
||||
}
|
||||
|
||||
// UpdateSettingRequest is the body for PUT /api/system/settings.
|
||||
type UpdateSettingRequest struct {
|
||||
Key string `json:"key" form:"key"`
|
||||
Value string `json:"value" form:"value"`
|
||||
}
|
||||
|
||||
// UpdateSettingResponse mirrors a settings entry plus a reload hint.
|
||||
type UpdateSettingResponse struct {
|
||||
database.SettingEntry
|
||||
ReloadRequired bool `json:"reload_required"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateSetting handles PUT /api/system/settings.
|
||||
func (h *SystemSettingsHandler) UpdateSetting(c *echo.Context) error {
|
||||
if h.settings == nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
|
||||
}
|
||||
var req UpdateSettingRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
resp, err := h.ApplySetting(c.Request().Context(), req.Key, req.Value)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ApplySetting validates, persists, and reloads a single setting. Shared by the
|
||||
// JSON API and the HTMX admin endpoint.
|
||||
func (h *SystemSettingsHandler) ApplySetting(ctx context.Context, key, value string) (UpdateSettingResponse, error) {
|
||||
if h.settings == nil {
|
||||
return UpdateSettingResponse{}, fmt.Errorf("settings registry not initialized")
|
||||
}
|
||||
if key == "" {
|
||||
return UpdateSettingResponse{}, fmt.Errorf("key is required")
|
||||
}
|
||||
def, ok := database.LookupDefault(key)
|
||||
if !ok {
|
||||
return UpdateSettingResponse{}, fmt.Errorf("unknown setting key: %s", key)
|
||||
}
|
||||
if err := validateSettingValue(def, value); err != nil {
|
||||
return UpdateSettingResponse{}, err
|
||||
}
|
||||
|
||||
desc := def.Description
|
||||
rType := pgtype.Text{}
|
||||
if def.Type != "" {
|
||||
rType = pgtype.Text{String: def.Type, Valid: true}
|
||||
}
|
||||
var minP, maxP pgtype.Text
|
||||
if def.Min != "" {
|
||||
minP = pgtype.Text{String: def.Min, Valid: true}
|
||||
}
|
||||
if def.Max != "" {
|
||||
maxP = pgtype.Text{String: def.Max, Valid: true}
|
||||
}
|
||||
if _, err := h.db.UpsertSystemSetting(ctx, database.UpsertSystemSettingParams{
|
||||
SettingKey: key,
|
||||
SettingValue: value,
|
||||
Description: pgtype.Text{String: desc, Valid: desc != ""},
|
||||
SettingType: rType,
|
||||
MinValue: minP,
|
||||
MaxValue: maxP,
|
||||
RequiresRestart: pgtype.Bool{Bool: def.RequiresRestart, Valid: true},
|
||||
Category: pgtype.Text{String: def.Category, Valid: def.Category != ""},
|
||||
}); err != nil {
|
||||
return UpdateSettingResponse{}, err
|
||||
}
|
||||
|
||||
h.settings.Reload(ctx)
|
||||
|
||||
resp := UpdateSettingResponse{ReloadRequired: def.RequiresRestart}
|
||||
for _, e := range h.settings.All() {
|
||||
if e.Key == key {
|
||||
resp.SettingEntry = e
|
||||
break
|
||||
}
|
||||
}
|
||||
if def.RequiresRestart {
|
||||
resp.Message = "Saved. Restart the server for this change to take full effect."
|
||||
} else {
|
||||
resp.Message = "Saved."
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// validateSettingValue checks a candidate value against the setting's type and bounds.
|
||||
func validateSettingValue(def database.SettingDefault, value string) error {
|
||||
switch def.Type {
|
||||
case database.SettingTypeInt:
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("value must be an integer")
|
||||
}
|
||||
if def.Min != "" {
|
||||
if mn, err := strconv.Atoi(def.Min); err == nil && n < mn {
|
||||
return fmt.Errorf("value must be >= %s", def.Min)
|
||||
}
|
||||
}
|
||||
if def.Max != "" {
|
||||
if mx, err := strconv.Atoi(def.Max); err == nil && n > mx {
|
||||
return fmt.Errorf("value must be <= %s", def.Max)
|
||||
}
|
||||
}
|
||||
case database.SettingTypeBool:
|
||||
if _, err := strconv.ParseBool(value); err != nil {
|
||||
return fmt.Errorf("value must be true or false")
|
||||
}
|
||||
case database.SettingTypeString:
|
||||
if value == "" {
|
||||
return fmt.Errorf("value must not be empty")
|
||||
}
|
||||
if def.Key == "default_timezone" {
|
||||
if _, err := time.LoadLocation(value); err != nil {
|
||||
return fmt.Errorf("invalid timezone: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Legacy scan-settings endpoints (retained for backward compatibility) ----
|
||||
|
||||
type UpdateScanSettingsRequest struct {
|
||||
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
|
||||
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
||||
@@ -51,6 +203,7 @@ func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.reload(c)
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
||||
}
|
||||
|
||||
@@ -88,6 +241,8 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
h.reload(c)
|
||||
|
||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
|
||||
AutoScanEnabled: req.AutoScanEnabled,
|
||||
@@ -96,6 +251,15 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
||||
}
|
||||
|
||||
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
||||
// Prefer the registry (single source of truth after Load).
|
||||
if h.settings != nil {
|
||||
interval := int32(h.settings.ScanPollInterval().Seconds())
|
||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||
ScanPollIntervalSeconds: interval,
|
||||
AutoScanEnabled: h.settings.AutoScanEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
||||
@@ -17,4 +17,10 @@ func registerSystemRoutes(cfg *Config) {
|
||||
// System configuration routes (admin-only)
|
||||
system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration)
|
||||
system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration)
|
||||
|
||||
// Unified tunable settings (admin-only). These back the admin UI's
|
||||
// editable System Settings sections and supersede the legacy
|
||||
// /api/libraries/scan-settings JSON routes.
|
||||
system.GET("/settings", cfg.SystemSettingsHandler.GetSettings)
|
||||
system.PUT("/settings", cfg.SystemSettingsHandler.UpdateSetting)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user