mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
feat(api): add log retention and max records feature overrides
Add special handling for log_retention_days and log_max_records features that are stored as separate Redis keys instead of in the features hash. - Store log overrides in dedicated keys (meta:log:retention_days, meta:log:max_records) for runtime access by log cleanup cron - Include log override values in ListFeatures response - Support clearing overrides by setting value to 0 - Add comprehensive tests for log override CRUD operations
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -10,6 +13,12 @@ import (
|
||||
)
|
||||
|
||||
const featuresKey = "meta:features"
|
||||
const (
|
||||
logRetentionFeatureKey = "log_retention_days"
|
||||
logMaxRecordsFeatureKey = "log_max_records"
|
||||
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.
|
||||
@@ -40,6 +49,18 @@ func (h *FeatureHandler) ListFeatures(c *gin.Context) {
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -70,11 +91,30 @@ func (h *FeatureHandler) UpdateFeatures(c *gin.Context) {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -92,15 +132,68 @@ func (h *FeatureHandler) UpdateFeatures(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
141
internal/api/feature_handler_test.go
Normal file
141
internal/api/feature_handler_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestFeatureHandler_UpdateFeatures_LogOverrides(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
|
||||
h := NewFeatureHandler(rdb)
|
||||
r := gin.New()
|
||||
r.PUT("/admin/features", h.UpdateFeatures)
|
||||
|
||||
body := []byte(`{"log_retention_days":7,"log_max_records":123}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/features", bytes.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
type resp struct {
|
||||
Updated map[string]string `json:"updated"`
|
||||
}
|
||||
var got resp
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Updated["log_retention_days"] != "7" {
|
||||
t.Fatalf("expected log_retention_days=7, got %q", got.Updated["log_retention_days"])
|
||||
}
|
||||
if got.Updated["log_max_records"] != "123" {
|
||||
t.Fatalf("expected log_max_records=123, got %q", got.Updated["log_max_records"])
|
||||
}
|
||||
|
||||
if v, _ := mr.Get("meta:log:retention_days"); v != "7" {
|
||||
t.Fatalf("expected redis retention_days=7, got %q", v)
|
||||
}
|
||||
if v, _ := mr.Get("meta:log:max_records"); v != "123" {
|
||||
t.Fatalf("expected redis max_records=123, got %q", v)
|
||||
}
|
||||
if v := mr.HGet("meta:features", "log_retention_days"); v != "" {
|
||||
t.Fatalf("expected no log_retention_days in meta:features, got %q", v)
|
||||
}
|
||||
if v := mr.HGet("meta:features", "log_max_records"); v != "" {
|
||||
t.Fatalf("expected no log_max_records in meta:features, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureHandler_UpdateFeatures_LogOverridesClear(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
|
||||
if err := rdb.Set(context.Background(), "meta:log:retention_days", "9", 0).Err(); err != nil {
|
||||
t.Fatalf("seed retention: %v", err)
|
||||
}
|
||||
if err := rdb.Set(context.Background(), "meta:log:max_records", "999", 0).Err(); err != nil {
|
||||
t.Fatalf("seed max_records: %v", err)
|
||||
}
|
||||
|
||||
h := NewFeatureHandler(rdb)
|
||||
r := gin.New()
|
||||
r.PUT("/admin/features", h.UpdateFeatures)
|
||||
|
||||
body := []byte(`{"log_retention_days":0,"log_max_records":0}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/features", bytes.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if mr.Exists("meta:log:retention_days") {
|
||||
t.Fatalf("expected retention_days to be cleared")
|
||||
}
|
||||
if mr.Exists("meta:log:max_records") {
|
||||
t.Fatalf("expected max_records to be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureHandler_ListFeatures_IncludesLogOverrides(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
|
||||
if err := rdb.HSet(context.Background(), "meta:features", map[string]any{
|
||||
"registration_code_enabled": "true",
|
||||
}).Err(); err != nil {
|
||||
t.Fatalf("seed features: %v", err)
|
||||
}
|
||||
if err := rdb.Set(context.Background(), "meta:log:retention_days", "14", 0).Err(); err != nil {
|
||||
t.Fatalf("seed retention: %v", err)
|
||||
}
|
||||
if err := rdb.Set(context.Background(), "meta:log:max_records", "2048", 0).Err(); err != nil {
|
||||
t.Fatalf("seed max_records: %v", err)
|
||||
}
|
||||
|
||||
h := NewFeatureHandler(rdb)
|
||||
r := gin.New()
|
||||
r.GET("/admin/features", h.ListFeatures)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/features", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Features map[string]string `json:"features"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Features["registration_code_enabled"] != "true" {
|
||||
t.Fatalf("expected registration_code_enabled=true, got %q", got.Features["registration_code_enabled"])
|
||||
}
|
||||
if got.Features["log_retention_days"] != "14" {
|
||||
t.Fatalf("expected log_retention_days=14, got %q", got.Features["log_retention_days"])
|
||||
}
|
||||
if got.Features["log_max_records"] != "2048" {
|
||||
t.Fatalf("expected log_max_records=2048, got %q", got.Features["log_max_records"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user