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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -10,6 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const featuresKey = "meta:features"
|
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.
|
// FeatureHandler manages lightweight feature flags stored in Redis for DP/CP runtime toggles.
|
||||||
// Values are stored as plain strings in a Redis hash.
|
// 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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read features", "details": err.Error()})
|
||||||
return
|
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})
|
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))
|
updates := make(map[string]string, len(req))
|
||||||
|
specialUpdates := make(map[string]string, 2)
|
||||||
for k, v := range req {
|
for k, v := range req {
|
||||||
key := strings.TrimSpace(k)
|
key := strings.TrimSpace(k)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
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) {
|
switch vv := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
updates[key] = strings.TrimSpace(vv)
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid feature updates"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
if err := h.rdb.HSet(c.Request.Context(), featuresKey, updates).Err(); err != nil {
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update features", "details": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range specialUpdates {
|
||||||
|
updates[k] = v
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"updated": updates})
|
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