mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
Add runtime feature flag to control whether request bodies are stored in logs. The Handler now accepts a Redis client to check the log_request_body_enabled feature flag before persisting log records. - Add logRequestBodyFeatureKey constant for feature flag - Inject Redis client into Handler for feature flag lookups - Strip request body from log records when feature is disabled - Update tests to pass Redis client to NewHandler
201 lines
5.6 KiB
Go
201 lines
5.6 KiB
Go
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} gin.H
|
|
// @Failure 500 {object} 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} gin.H
|
|
// @Failure 400 {object} gin.H
|
|
// @Failure 500 {object} 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")
|
|
}
|
|
}
|