package api import ( "context" "fmt" "math" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" ) const featuresKey = "meta:features" const ( logRetentionFeatureKey = "log_retention_days" logMaxRecordsFeatureKey = "log_max_records" logRequestBodyFeatureKey = "log_request_body_enabled" logRetentionRedisKey = "meta:log:retention_days" logMaxRecordsRedisKey = "meta:log:max_records" ) // FeatureHandler manages lightweight feature flags stored in Redis for DP/CP runtime toggles. // Values are stored as plain strings in a Redis hash. type FeatureHandler struct { rdb *redis.Client } func NewFeatureHandler(rdb *redis.Client) *FeatureHandler { return &FeatureHandler{rdb: rdb} } // ListFeatures godoc // @Summary List feature flags // @Description Returns all feature flags stored in Redis (meta:features) // @Tags admin // @Produce json // @Security AdminAuth // @Success 200 {object} ResponseEnvelope{data=gin.H} // @Failure 500 {object} ResponseEnvelope{data=gin.H} // @Router /admin/features [get] func (h *FeatureHandler) ListFeatures(c *gin.Context) { if h.rdb == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "redis not configured"}) return } m, err := h.rdb.HGetAll(c.Request.Context(), featuresKey).Result() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read features", "details": err.Error()}) return } if v, err := h.rdb.Get(c.Request.Context(), logRetentionRedisKey).Result(); err == nil { m[logRetentionFeatureKey] = strings.TrimSpace(v) } else if err != redis.Nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read log retention", "details": err.Error()}) return } if v, err := h.rdb.Get(c.Request.Context(), logMaxRecordsRedisKey).Result(); err == nil { m[logMaxRecordsFeatureKey] = strings.TrimSpace(v) } else if err != redis.Nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read log max records", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"features": m}) } type UpdateFeaturesRequest map[string]any // UpdateFeatures godoc // @Summary Update feature flags // @Description Updates selected feature flags (meta:features). Values are stored as strings. // @Tags admin // @Accept json // @Produce json // @Security AdminAuth // @Param request body object true "Feature map" // @Success 200 {object} ResponseEnvelope{data=gin.H} // @Failure 400 {object} ResponseEnvelope{data=gin.H} // @Failure 500 {object} ResponseEnvelope{data=gin.H} // @Router /admin/features [put] func (h *FeatureHandler) UpdateFeatures(c *gin.Context) { if h.rdb == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "redis not configured"}) return } var req UpdateFeaturesRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } updates := make(map[string]string, len(req)) specialUpdates := make(map[string]string, 2) for k, v := range req { key := strings.TrimSpace(k) if key == "" { continue } switch key { case logRetentionFeatureKey: value, err := updateLogOverride(c.Request.Context(), h.rdb, logRetentionRedisKey, v) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } specialUpdates[key] = value continue case logMaxRecordsFeatureKey: value, err := updateLogOverride(c.Request.Context(), h.rdb, logMaxRecordsRedisKey, v) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } specialUpdates[key] = value continue } switch vv := v.(type) { case string: updates[key] = strings.TrimSpace(vv) case bool: if vv { updates[key] = "true" } else { updates[key] = "false" } case float64: // JSON numbers decode as float64; keep as integer-looking string when possible. updates[key] = fmt.Sprintf("%v", vv) default: updates[key] = fmt.Sprintf("%v", vv) } } if len(updates) == 0 && len(specialUpdates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no valid feature updates"}) return } if len(updates) > 0 { if err := h.rdb.HSet(c.Request.Context(), featuresKey, updates).Err(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update features", "details": err.Error()}) return } } for k, v := range specialUpdates { updates[k] = v } c.JSON(http.StatusOK, gin.H{"updated": updates}) } func updateLogOverride(ctx context.Context, rdb *redis.Client, key string, value any) (string, error) { n, err := parsePositiveInt64(value) if err != nil { return "", err } if n <= 0 { if err := rdb.Del(ctx, key).Err(); err != nil && err != redis.Nil { return "", fmt.Errorf("failed to clear %s: %w", key, err) } return "0", nil } raw := strconv.FormatInt(n, 10) if err := rdb.Set(ctx, key, raw, 0).Err(); err != nil { return "", fmt.Errorf("failed to update %s: %w", key, err) } return raw, nil } func parsePositiveInt64(value any) (int64, error) { switch v := value.(type) { case nil: return 0, nil case float64: if v < 0 { return 0, fmt.Errorf("value must be >= 0") } if v != math.Trunc(v) { return 0, fmt.Errorf("value must be an integer") } return int64(v), nil case string: trimmed := strings.TrimSpace(v) if trimmed == "" { return 0, nil } n, err := strconv.ParseInt(trimmed, 10, 64) if err != nil { return 0, fmt.Errorf("value must be an integer") } if n < 0 { return 0, fmt.Errorf("value must be >= 0") } return n, nil default: return 0, fmt.Errorf("value must be an integer") } }