mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 09:37:53 +00:00
feat(api): wrap JSON responses in envelope
Add response envelope middleware to standardize JSON responses as
`{code,data,message}` with consistent business codes across endpoints.
Update Swagger annotations and tests to reflect the new response shape.
BREAKING CHANGE: API responses are now wrapped in a response envelope; clients must read payloads from `data` and handle `code`/`message` fields.
This commit is contained in:
@@ -292,6 +292,8 @@ func main() {
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
|
||||
// 动态设置 Swagger Host
|
||||
if cfg.Server.SwaggerHost != "" {
|
||||
docs.SwaggerInfo.Host = cfg.Server.SwaggerHost
|
||||
|
||||
@@ -29,10 +29,10 @@ type UpdateAccessRequest struct {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Success 200 {object} AccessResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AccessResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters/{id}/access [get]
|
||||
func (h *Handler) GetMasterAccess(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -61,10 +61,10 @@ func (h *Handler) GetMasterAccess(c *gin.Context) {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Param request body UpdateAccessRequest true "Access settings"
|
||||
// @Success 200 {object} AccessResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AccessResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters/{id}/access [put]
|
||||
func (h *Handler) UpdateMasterAccess(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -144,10 +144,10 @@ func (h *Handler) UpdateMasterAccess(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Key ID"
|
||||
// @Success 200 {object} AccessResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AccessResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/keys/{id}/access [get]
|
||||
func (h *Handler) GetKeyAccess(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -174,10 +174,10 @@ func (h *Handler) GetKeyAccess(c *gin.Context) {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Key ID"
|
||||
// @Param request body UpdateAccessRequest true "Access settings"
|
||||
// @Success 200 {object} AccessResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AccessResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/keys/{id}/access [put]
|
||||
func (h *Handler) UpdateKeyAccess(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
|
||||
@@ -54,9 +54,9 @@ type CreateMasterRequest struct {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param master body CreateMasterRequest true "Master Info"
|
||||
// @Success 201 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 201 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters [post]
|
||||
func (h *AdminHandler) CreateMaster(c *gin.Context) {
|
||||
var req CreateMasterRequest
|
||||
@@ -135,8 +135,8 @@ func toMasterView(m model.Master) MasterView {
|
||||
// @Param page query int false "page (1-based)"
|
||||
// @Param limit query int false "limit (default 50, max 200)"
|
||||
// @Param search query string false "search by name/group"
|
||||
// @Success 200 {array} MasterView
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=[]MasterView}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters [get]
|
||||
func (h *AdminHandler) ListMasters(c *gin.Context) {
|
||||
var masters []model.Master
|
||||
@@ -162,10 +162,10 @@ func (h *AdminHandler) ListMasters(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Success 200 {object} MasterView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=MasterView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters/{id} [get]
|
||||
func (h *AdminHandler) GetMaster(c *gin.Context) {
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
@@ -208,10 +208,10 @@ type UpdateMasterRequest struct {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Param request body UpdateMasterRequest true "Update payload"
|
||||
// @Success 200 {object} MasterView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=MasterView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters/{id} [put]
|
||||
func (h *AdminHandler) UpdateMaster(c *gin.Context) {
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
@@ -313,10 +313,10 @@ type ManageMasterRequest struct {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Param request body ManageMasterRequest true "Action"
|
||||
// @Success 200 {object} MasterView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=MasterView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters/{id}/manage [post]
|
||||
func (h *AdminHandler) ManageMaster(c *gin.Context) {
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
@@ -370,10 +370,10 @@ func (h *AdminHandler) ManageMaster(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters/{id} [delete]
|
||||
func (h *AdminHandler) DeleteMaster(c *gin.Context) {
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
@@ -404,11 +404,11 @@ func (h *AdminHandler) DeleteMaster(c *gin.Context) {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Param request body IssueChildKeyRequest true "Key Request"
|
||||
// @Success 201 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 403 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 201 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 403 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters/{id}/keys [post]
|
||||
func (h *AdminHandler) IssueChildKeyForMaster(c *gin.Context) {
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
|
||||
@@ -2,13 +2,13 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/ez-api/foundation/tokenhash"
|
||||
@@ -43,6 +43,7 @@ func TestAdmin_IssueChildKeyForMaster_IssuedByAdminAndSynced(t *testing.T) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.POST("/admin/masters/:id/keys", adminHandler.IssueChildKeyForMaster)
|
||||
|
||||
body := []byte(`{"scopes":"chat:write"}`)
|
||||
@@ -55,8 +56,12 @@ func TestAdmin_IssueChildKeyForMaster_IssuedByAdminAndSynced(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
env := decodeEnvelope(t, rr, &resp)
|
||||
if env.Code != "ok" {
|
||||
t.Fatalf("expected code=ok, got %q", env.Code)
|
||||
}
|
||||
if env.Message != "" {
|
||||
t.Fatalf("expected empty message, got %q", env.Message)
|
||||
}
|
||||
if resp["issued_by"] != "admin" {
|
||||
t.Fatalf("expected issued_by=admin, got=%v", resp["issued_by"])
|
||||
|
||||
@@ -90,8 +90,8 @@ type ListAlertsResponse struct {
|
||||
// @Param status query string false "filter by status (active, acknowledged, resolved, dismissed)"
|
||||
// @Param severity query string false "filter by severity (info, warning, critical)"
|
||||
// @Param type query string false "filter by type (rate_limit, error_spike, quota_exceeded, key_disabled, key_expired, provider_down, traffic_spike)"
|
||||
// @Success 200 {object} ListAlertsResponse
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=ListAlertsResponse}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/alerts [get]
|
||||
func (h *AlertHandler) ListAlerts(c *gin.Context) {
|
||||
limit, offset := parseLimitOffset(c)
|
||||
@@ -140,9 +140,9 @@ func (h *AlertHandler) ListAlerts(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Alert ID"
|
||||
// @Success 200 {object} AlertView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AlertView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/alerts/{id} [get]
|
||||
func (h *AlertHandler) GetAlert(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -180,9 +180,9 @@ type CreateAlertRequest struct {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param request body CreateAlertRequest true "Alert data"
|
||||
// @Success 201 {object} AlertView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 201 {object} ResponseEnvelope{data=AlertView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/alerts [post]
|
||||
func (h *AlertHandler) CreateAlert(c *gin.Context) {
|
||||
var req CreateAlertRequest
|
||||
@@ -247,10 +247,10 @@ type AckAlertRequest struct {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Alert ID"
|
||||
// @Param request body AckAlertRequest false "Ack data"
|
||||
// @Success 200 {object} AlertView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AlertView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/alerts/{id}/ack [post]
|
||||
func (h *AlertHandler) AcknowledgeAlert(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -294,10 +294,10 @@ func (h *AlertHandler) AcknowledgeAlert(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Alert ID"
|
||||
// @Success 200 {object} AlertView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AlertView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/alerts/{id}/resolve [post]
|
||||
func (h *AlertHandler) ResolveAlert(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -337,10 +337,10 @@ func (h *AlertHandler) ResolveAlert(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Alert ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/alerts/{id} [delete]
|
||||
func (h *AlertHandler) DismissAlert(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -379,8 +379,8 @@ type AlertStats struct {
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Success 200 {object} AlertStats
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AlertStats}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/alerts/stats [get]
|
||||
func (h *AlertHandler) GetAlertStats(c *gin.Context) {
|
||||
var total, active, acknowledged, resolved, critical, warning, info int64
|
||||
@@ -435,8 +435,8 @@ func toAlertThresholdView(cfg model.AlertThresholdConfig) AlertThresholdView {
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Success 200 {object} AlertThresholdView
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AlertThresholdView}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/alerts/thresholds [get]
|
||||
func (h *AlertHandler) GetAlertThresholds(c *gin.Context) {
|
||||
cfg, err := h.loadThresholdConfig()
|
||||
@@ -466,9 +466,9 @@ type UpdateAlertThresholdsRequest struct {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param request body UpdateAlertThresholdsRequest true "Threshold configuration"
|
||||
// @Success 200 {object} AlertThresholdView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AlertThresholdView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/alerts/thresholds [put]
|
||||
func (h *AlertHandler) UpdateAlertThresholds(c *gin.Context) {
|
||||
var req UpdateAlertThresholdsRequest
|
||||
|
||||
@@ -2,12 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -29,6 +29,7 @@ func setupAlertTestDB(t *testing.T) *gorm.DB {
|
||||
func setupAlertRouter(db *gorm.DB) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
handler := NewAlertHandler(db)
|
||||
|
||||
r.GET("/admin/alerts/thresholds", handler.GetAlertThresholds)
|
||||
@@ -53,9 +54,7 @@ func TestGetAlertThresholdsDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp AlertThresholdView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, w, &resp)
|
||||
|
||||
// Should return defaults
|
||||
if resp.GlobalQPS != 100 {
|
||||
@@ -91,9 +90,7 @@ func TestUpdateAlertThresholds(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp AlertThresholdView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, w, &resp)
|
||||
|
||||
if resp.GlobalQPS != 500 {
|
||||
t.Errorf("expected GlobalQPS=500, got %d", resp.GlobalQPS)
|
||||
@@ -162,9 +159,7 @@ func TestCreateAlertWithTrafficSpikeType(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp AlertView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, w, &resp)
|
||||
|
||||
if resp.Type != "traffic_spike" {
|
||||
t.Errorf("expected type=traffic_spike, got %s", resp.Type)
|
||||
@@ -203,9 +198,7 @@ func TestListAlertsWithTypeFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp ListAlertsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, w, &resp)
|
||||
|
||||
if resp.Total != 2 {
|
||||
t.Errorf("expected 2 traffic_spike alerts, got %d", resp.Total)
|
||||
@@ -242,9 +235,7 @@ func TestAlertStatsIncludesAllAlerts(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp AlertStats
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, w, &resp)
|
||||
|
||||
if resp.Total != 3 {
|
||||
t.Errorf("expected total=3, got %d", resp.Total)
|
||||
|
||||
@@ -17,9 +17,9 @@ import (
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param key body dto.APIKeyDTO true "API key payload"
|
||||
// @Success 201 {object} model.APIKey
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 201 {object} ResponseEnvelope{data=model.APIKey}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/api-keys [post]
|
||||
func (h *Handler) CreateAPIKey(c *gin.Context) {
|
||||
var req dto.APIKeyDTO
|
||||
@@ -100,8 +100,8 @@ func (h *Handler) CreateAPIKey(c *gin.Context) {
|
||||
// @Param limit query int false "limit (default 50, max 200)"
|
||||
// @Param group_id query int false "filter by group_id"
|
||||
// @Param status query string false "filter by status (active, suspended, auto_disabled, manual_disabled)"
|
||||
// @Success 200 {array} model.APIKey
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=[]model.APIKey}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/api-keys [get]
|
||||
func (h *Handler) ListAPIKeys(c *gin.Context) {
|
||||
var keys []model.APIKey
|
||||
@@ -128,10 +128,10 @@ func (h *Handler) ListAPIKeys(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "APIKey ID"
|
||||
// @Success 200 {object} model.APIKey
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=model.APIKey}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/api-keys/{id} [get]
|
||||
func (h *Handler) GetAPIKey(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -155,10 +155,10 @@ func (h *Handler) GetAPIKey(c *gin.Context) {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "APIKey ID"
|
||||
// @Param key body dto.APIKeyDTO true "API key payload"
|
||||
// @Success 200 {object} model.APIKey
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=model.APIKey}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/api-keys/{id} [put]
|
||||
func (h *Handler) UpdateAPIKey(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -275,10 +275,10 @@ func (h *Handler) UpdateAPIKey(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "APIKey ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/api-keys/{id} [delete]
|
||||
func (h *Handler) DeleteAPIKey(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
|
||||
@@ -25,8 +25,8 @@ type APIKeyStatsSummaryResponse struct {
|
||||
// @Security AdminAuth
|
||||
// @Param since query int false "Start time (unix seconds)"
|
||||
// @Param until query int false "End time (unix seconds)"
|
||||
// @Success 200 {object} APIKeyStatsSummaryResponse
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=APIKeyStatsSummaryResponse}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/apikey-stats/summary [get]
|
||||
func (h *AdminHandler) GetAPIKeyStatsSummary(c *gin.Context) {
|
||||
if h == nil || h.db == nil {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -45,6 +45,7 @@ func TestAdminHandler_GetAPIKeyStatsSummary(t *testing.T) {
|
||||
|
||||
handler := &AdminHandler{db: db}
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/admin/apikey-stats/summary", handler.GetAPIKeyStatsSummary)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/apikey-stats/summary", nil)
|
||||
@@ -56,8 +57,9 @@ func TestAdminHandler_GetAPIKeyStatsSummary(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp APIKeyStatsSummaryResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
env := decodeEnvelope(t, rec, &resp)
|
||||
if env.Code != "ok" {
|
||||
t.Fatalf("expected code=ok, got %q", env.Code)
|
||||
}
|
||||
if resp.TotalRequests != 15 || resp.SuccessRequests != 12 || resp.FailureRequests != 3 {
|
||||
t.Fatalf("totals mismatch: %+v", resp)
|
||||
|
||||
@@ -171,8 +171,8 @@ type WhoamiResponse struct {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Security MasterAuth
|
||||
// @Success 200 {object} WhoamiResponse
|
||||
// @Failure 401 {object} gin.H "Invalid or missing token"
|
||||
// @Success 200 {object} ResponseEnvelope{data=WhoamiResponse}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H} "Invalid or missing token"
|
||||
// @Router /auth/whoami [get]
|
||||
func (h *AuthHandler) Whoami(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -9,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/ez-api/foundation/tokenhash"
|
||||
@@ -58,6 +58,7 @@ func TestAuthHandler_Whoami_InvalidIssuedAtEpoch_Returns401(t *testing.T) {
|
||||
mr.HSet("auth:master:1", "status", "active")
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/auth/whoami", handler.Whoami)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/whoami", nil)
|
||||
@@ -83,6 +84,7 @@ func TestAuthHandler_Whoami_InvalidMasterEpoch_Returns401(t *testing.T) {
|
||||
mr.HSet("auth:master:1", "status", "active")
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/auth/whoami", handler.Whoami)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/whoami", nil)
|
||||
@@ -128,6 +130,7 @@ func TestAuthHandler_Whoami_KeyResponseIncludesIPRules(t *testing.T) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/auth/whoami", handler.Whoami)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/whoami", nil)
|
||||
@@ -140,8 +143,12 @@ func TestAuthHandler_Whoami_KeyResponseIncludesIPRules(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
env := decodeEnvelope(t, rr, &resp)
|
||||
if env.Code != "ok" {
|
||||
t.Fatalf("expected code=ok, got %q", env.Code)
|
||||
}
|
||||
if env.Message != "" {
|
||||
t.Fatalf("expected empty message, got %q", env.Message)
|
||||
}
|
||||
if resp["allow_ips"] != "1.2.3.4" {
|
||||
t.Fatalf("expected allow_ips, got %v", resp["allow_ips"])
|
||||
@@ -187,6 +194,7 @@ func TestAuthHandler_Whoami_ExpiredKey_Returns401(t *testing.T) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/auth/whoami", handler.Whoami)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/whoami", nil)
|
||||
@@ -199,8 +207,12 @@ func TestAuthHandler_Whoami_ExpiredKey_Returns401(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
env := decodeEnvelope(t, rr, &resp)
|
||||
if env.Code != "unauthorized" {
|
||||
t.Fatalf("expected code=unauthorized, got %q", env.Code)
|
||||
}
|
||||
if env.Message != "token has expired" {
|
||||
t.Fatalf("expected message 'token has expired', got %q", env.Message)
|
||||
}
|
||||
if resp["error"] != "token has expired" {
|
||||
t.Fatalf("expected 'token has expired' error, got %v", resp["error"])
|
||||
|
||||
@@ -57,9 +57,9 @@ func isAllowedStatus(raw string, allowed ...string) bool {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param request body BatchActionRequest true "Batch payload"
|
||||
// @Success 200 {object} BatchResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=BatchResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters/batch [post]
|
||||
func (h *AdminHandler) BatchMasters(c *gin.Context) {
|
||||
var req BatchActionRequest
|
||||
@@ -112,9 +112,9 @@ func (h *AdminHandler) BatchMasters(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param request body BatchActionRequest true "Batch payload"
|
||||
// @Success 200 {object} BatchResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=BatchResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/api-keys/batch [post]
|
||||
func (h *Handler) BatchAPIKeys(c *gin.Context) {
|
||||
var req BatchActionRequest
|
||||
@@ -196,9 +196,9 @@ func (h *Handler) BatchAPIKeys(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param request body BatchActionRequest true "Batch payload"
|
||||
// @Success 200 {object} BatchResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=BatchResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/models/batch [post]
|
||||
func (h *Handler) BatchModels(c *gin.Context) {
|
||||
var req BatchActionRequest
|
||||
@@ -248,9 +248,9 @@ func (h *Handler) BatchModels(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param request body BatchActionRequest true "Batch payload"
|
||||
// @Success 200 {object} BatchResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=BatchResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/bindings/batch [post]
|
||||
func (h *Handler) BatchBindings(c *gin.Context) {
|
||||
var req BatchActionRequest
|
||||
|
||||
@@ -19,9 +19,9 @@ import (
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param binding body dto.BindingDTO true "Binding Info"
|
||||
// @Success 201 {object} model.Binding
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 201 {object} ResponseEnvelope{data=model.Binding}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/bindings [post]
|
||||
func (h *Handler) CreateBinding(c *gin.Context) {
|
||||
var req dto.BindingDTO
|
||||
@@ -84,8 +84,8 @@ func (h *Handler) CreateBinding(c *gin.Context) {
|
||||
// @Param page query int false "page (1-based)"
|
||||
// @Param limit query int false "limit (default 50, max 200)"
|
||||
// @Param search query string false "search by namespace/public_model"
|
||||
// @Success 200 {array} model.Binding
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=[]model.Binding}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/bindings [get]
|
||||
func (h *Handler) ListBindings(c *gin.Context) {
|
||||
var out []model.Binding
|
||||
@@ -109,10 +109,10 @@ func (h *Handler) ListBindings(c *gin.Context) {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Binding ID"
|
||||
// @Param binding body dto.BindingDTO true "Binding Info"
|
||||
// @Success 200 {object} model.Binding
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=model.Binding}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/bindings/{id} [put]
|
||||
func (h *Handler) UpdateBinding(c *gin.Context) {
|
||||
idParam := c.Param("id")
|
||||
@@ -180,10 +180,10 @@ func (h *Handler) UpdateBinding(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Binding ID"
|
||||
// @Success 200 {object} model.Binding
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=model.Binding}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/bindings/{id} [get]
|
||||
func (h *Handler) GetBinding(c *gin.Context) {
|
||||
idParam := c.Param("id")
|
||||
@@ -207,10 +207,10 @@ func (h *Handler) GetBinding(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Binding ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/bindings/{id} [delete]
|
||||
func (h *Handler) DeleteBinding(c *gin.Context) {
|
||||
idParam := c.Param("id")
|
||||
|
||||
@@ -187,9 +187,9 @@ type DashboardSummaryResponse struct {
|
||||
// @Param since query int false "unix seconds"
|
||||
// @Param until query int false "unix seconds"
|
||||
// @Param include_trends query bool false "include trend data comparing to previous period"
|
||||
// @Success 200 {object} DashboardSummaryResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=DashboardSummaryResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/dashboard/summary [get]
|
||||
func (h *DashboardHandler) GetSummary(c *gin.Context) {
|
||||
rng, err := parseStatsRange(c)
|
||||
|
||||
@@ -37,8 +37,8 @@ func NewFeatureHandler(rdb *redis.Client) *FeatureHandler {
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @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 {
|
||||
@@ -75,9 +75,9 @@ type UpdateFeaturesRequest map[string]any
|
||||
// @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
|
||||
// @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 {
|
||||
|
||||
@@ -3,12 +3,12 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
@@ -21,6 +21,7 @@ func TestFeatureHandler_UpdateFeatures_LogOverrides(t *testing.T) {
|
||||
|
||||
h := NewFeatureHandler(rdb)
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.PUT("/admin/features", h.UpdateFeatures)
|
||||
|
||||
body := []byte(`{"log_retention_days":7,"log_max_records":123}`)
|
||||
@@ -36,8 +37,12 @@ func TestFeatureHandler_UpdateFeatures_LogOverrides(t *testing.T) {
|
||||
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)
|
||||
env := decodeEnvelope(t, rr, &got)
|
||||
if env.Code != "ok" {
|
||||
t.Fatalf("expected code=ok, got %q", env.Code)
|
||||
}
|
||||
if env.Message != "" {
|
||||
t.Fatalf("expected empty message, got %q", env.Message)
|
||||
}
|
||||
if got.Updated["log_retention_days"] != "7" {
|
||||
t.Fatalf("expected log_retention_days=7, got %q", got.Updated["log_retention_days"])
|
||||
@@ -75,6 +80,7 @@ func TestFeatureHandler_UpdateFeatures_LogOverridesClear(t *testing.T) {
|
||||
|
||||
h := NewFeatureHandler(rdb)
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.PUT("/admin/features", h.UpdateFeatures)
|
||||
|
||||
body := []byte(`{"log_retention_days":0,"log_max_records":0}`)
|
||||
@@ -101,6 +107,7 @@ func TestFeatureHandler_UpdateFeatures_RegularKeys(t *testing.T) {
|
||||
|
||||
h := NewFeatureHandler(rdb)
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.PUT("/admin/features", h.UpdateFeatures)
|
||||
|
||||
body := []byte(`{"dp_context_preflight_enabled":false,"dp_state_store_backend":"redis","dp_claude_cross_upstream":"false","custom_number":12}`)
|
||||
@@ -115,8 +122,9 @@ func TestFeatureHandler_UpdateFeatures_RegularKeys(t *testing.T) {
|
||||
var got struct {
|
||||
Updated map[string]string `json:"updated"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
env := decodeEnvelope(t, rr, &got)
|
||||
if env.Code != "ok" {
|
||||
t.Fatalf("expected code=ok, got %q", env.Code)
|
||||
}
|
||||
if got.Updated["dp_context_preflight_enabled"] != "false" {
|
||||
t.Fatalf("expected dp_context_preflight_enabled=false, got %q", got.Updated["dp_context_preflight_enabled"])
|
||||
@@ -153,6 +161,7 @@ func TestFeatureHandler_UpdateFeatures_MixedKeys(t *testing.T) {
|
||||
|
||||
h := NewFeatureHandler(rdb)
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.PUT("/admin/features", h.UpdateFeatures)
|
||||
|
||||
body := []byte(`{"dp_context_preflight_enabled":true,"log_retention_days":5}`)
|
||||
@@ -167,8 +176,9 @@ func TestFeatureHandler_UpdateFeatures_MixedKeys(t *testing.T) {
|
||||
var got struct {
|
||||
Updated map[string]string `json:"updated"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
env := decodeEnvelope(t, rr, &got)
|
||||
if env.Code != "ok" {
|
||||
t.Fatalf("expected code=ok, got %q", env.Code)
|
||||
}
|
||||
if got.Updated["dp_context_preflight_enabled"] != "true" {
|
||||
t.Fatalf("expected dp_context_preflight_enabled=true, got %q", got.Updated["dp_context_preflight_enabled"])
|
||||
@@ -205,6 +215,7 @@ func TestFeatureHandler_ListFeatures_IncludesLogOverrides(t *testing.T) {
|
||||
|
||||
h := NewFeatureHandler(rdb)
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/admin/features", h.ListFeatures)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/features", nil)
|
||||
@@ -218,8 +229,9 @@ func TestFeatureHandler_ListFeatures_IncludesLogOverrides(t *testing.T) {
|
||||
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)
|
||||
env := decodeEnvelope(t, rr, &got)
|
||||
if env.Code != "ok" {
|
||||
t.Fatalf("expected code=ok, got %q", env.Code)
|
||||
}
|
||||
if got.Features["registration_code_enabled"] != "true" {
|
||||
t.Fatalf("expected registration_code_enabled=true, got %q", got.Features["registration_code_enabled"])
|
||||
|
||||
@@ -62,9 +62,9 @@ func (h *Handler) logBaseQuery() *gorm.DB {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param model body dto.ModelDTO true "Model Info"
|
||||
// @Success 201 {object} model.Model
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 201 {object} ResponseEnvelope{data=model.Model}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/models [post]
|
||||
func (h *Handler) CreateModel(c *gin.Context) {
|
||||
var req dto.ModelDTO
|
||||
@@ -123,8 +123,8 @@ func (h *Handler) CreateModel(c *gin.Context) {
|
||||
// @Param page query int false "page (1-based)"
|
||||
// @Param limit query int false "limit (default 50, max 200)"
|
||||
// @Param search query string false "search by name/kind"
|
||||
// @Success 200 {array} model.Model
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=[]model.Model}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/models [get]
|
||||
func (h *Handler) ListModels(c *gin.Context) {
|
||||
var models []model.Model
|
||||
@@ -148,10 +148,10 @@ func (h *Handler) ListModels(c *gin.Context) {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Model ID"
|
||||
// @Param model body dto.ModelDTO true "Model Info"
|
||||
// @Success 200 {object} model.Model
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=model.Model}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/models/{id} [put]
|
||||
func (h *Handler) UpdateModel(c *gin.Context) {
|
||||
idParam := c.Param("id")
|
||||
@@ -222,10 +222,10 @@ func (h *Handler) UpdateModel(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Model ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/models/{id} [delete]
|
||||
func (h *Handler) DeleteModel(c *gin.Context) {
|
||||
idParam := c.Param("id")
|
||||
@@ -260,8 +260,8 @@ func (h *Handler) DeleteModel(c *gin.Context) {
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/sync/snapshot [post]
|
||||
func (h *Handler) SyncSnapshot(c *gin.Context) {
|
||||
if err := h.sync.SyncAll(h.db); err != nil {
|
||||
@@ -278,8 +278,8 @@ func (h *Handler) SyncSnapshot(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param log body model.LogRecord true "Log Record"
|
||||
// @Success 202 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Success 202 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /logs [post]
|
||||
func (h *Handler) IngestLog(c *gin.Context) {
|
||||
var rec model.LogRecord
|
||||
|
||||
@@ -47,9 +47,9 @@ type apiKeyStatsFlushEntry struct {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body statsFlushRequest true "Stats to flush"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /internal/stats/flush [post]
|
||||
func (h *InternalHandler) FlushStats(c *gin.Context) {
|
||||
if h == nil || h.db == nil {
|
||||
@@ -124,9 +124,9 @@ func (h *InternalHandler) FlushStats(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body apiKeyStatsFlushRequest true "Stats to flush"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /internal/apikey-stats/flush [post]
|
||||
func (h *InternalHandler) FlushAPIKeyStats(c *gin.Context) {
|
||||
if h == nil || h.db == nil {
|
||||
@@ -287,9 +287,9 @@ const alertDeduplicationCooldown = 5 * time.Minute
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body reportAlertsRequest true "Alerts to report"
|
||||
// @Success 200 {object} reportAlertsResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=reportAlertsResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /internal/alerts/report [post]
|
||||
func (h *InternalHandler) ReportAlerts(c *gin.Context) {
|
||||
if h == nil || h.db == nil {
|
||||
|
||||
@@ -89,10 +89,10 @@ func toIPBanView(ban *model.IPBan) IPBanView {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param ban body CreateIPBanRequest true "IP Ban Info"
|
||||
// @Success 201 {object} IPBanView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 409 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 201 {object} ResponseEnvelope{data=IPBanView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 409 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/ip-bans [post]
|
||||
func (h *IPBanHandler) Create(c *gin.Context) {
|
||||
var req CreateIPBanRequest
|
||||
@@ -139,8 +139,8 @@ func (h *IPBanHandler) Create(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param status query string false "Filter by status (active, expired)"
|
||||
// @Success 200 {array} IPBanView
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=[]IPBanView}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/ip-bans [get]
|
||||
func (h *IPBanHandler) List(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
@@ -167,9 +167,9 @@ func (h *IPBanHandler) List(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "IP Ban ID"
|
||||
// @Success 200 {object} IPBanView
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=IPBanView}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/ip-bans/{id} [get]
|
||||
func (h *IPBanHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -200,11 +200,11 @@ func (h *IPBanHandler) Get(c *gin.Context) {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "IP Ban ID"
|
||||
// @Param ban body UpdateIPBanRequest true "IP Ban Update"
|
||||
// @Success 200 {object} IPBanView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 409 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=IPBanView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 409 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/ip-bans/{id} [put]
|
||||
func (h *IPBanHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -250,8 +250,8 @@ func (h *IPBanHandler) Update(c *gin.Context) {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "IP Ban ID"
|
||||
// @Success 204
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/ip-bans/{id} [delete]
|
||||
func (h *IPBanHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
@@ -167,8 +167,8 @@ func (h *MasterHandler) masterLogBase(masterID uint) (*gorm.DB, error) {
|
||||
// @Param group query string false "route group"
|
||||
// @Param model query string false "model"
|
||||
// @Param status_code query int false "status code"
|
||||
// @Success 200 {object} ListLogsResponse
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=ListLogsResponse}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/logs [get]
|
||||
func (h *Handler) ListLogs(c *gin.Context) {
|
||||
limit, offset := parseLimitOffset(c)
|
||||
@@ -267,9 +267,9 @@ type DeleteLogsResponse struct {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param request body DeleteLogsRequest true "Delete filters"
|
||||
// @Success 200 {object} DeleteLogsResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=DeleteLogsResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/logs [delete]
|
||||
func (h *Handler) DeleteLogs(c *gin.Context) {
|
||||
var req DeleteLogsRequest
|
||||
@@ -357,10 +357,10 @@ func (h *Handler) deleteLogsBefore(cutoff time.Time, keyID uint, modelName strin
|
||||
// @Param since query int false "unix seconds"
|
||||
// @Param until query int false "unix seconds"
|
||||
// @Param group_by query string false "group by dimension: model, day, month, hour, minute. Returns GroupedStatsResponse when specified." Enums(model, day, month, hour, minute)
|
||||
// @Success 200 {object} LogStatsResponse "Default aggregated stats (when group_by is not specified)"
|
||||
// @Success 200 {object} GroupedStatsResponse "Grouped stats (when group_by is specified)"
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=LogStatsResponse} "Default aggregated stats (when group_by is not specified)"
|
||||
// @Success 200 {object} ResponseEnvelope{data=GroupedStatsResponse} "Grouped stats (when group_by is specified)"
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/logs/stats [get]
|
||||
func (h *Handler) LogStats(c *gin.Context) {
|
||||
q := h.logBaseQuery()
|
||||
@@ -767,9 +767,9 @@ func buildTrafficChartSeriesResponse(rows []trafficBucketRow, topN int, granular
|
||||
// @Param since query int false "Start time (unix seconds), defaults to 24h ago"
|
||||
// @Param until query int false "End time (unix seconds), defaults to now"
|
||||
// @Param top_n query int false "Number of top models to return (1-20), defaults to 5"
|
||||
// @Success 200 {object} TrafficChartResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=TrafficChartResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/logs/stats/traffic-chart [get]
|
||||
func (h *Handler) GetTrafficChart(c *gin.Context) {
|
||||
// Parse granularity
|
||||
@@ -869,9 +869,9 @@ func (h *Handler) GetTrafficChart(c *gin.Context) {
|
||||
// @Param until query int false "unix seconds"
|
||||
// @Param model query string false "model"
|
||||
// @Param status_code query int false "status code"
|
||||
// @Success 200 {object} ListMasterLogsResponse
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=ListMasterLogsResponse}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /v1/logs [get]
|
||||
func (h *MasterHandler) ListSelfLogs(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
@@ -928,9 +928,9 @@ func (h *MasterHandler) ListSelfLogs(c *gin.Context) {
|
||||
// @Security MasterAuth
|
||||
// @Param since query int false "unix seconds"
|
||||
// @Param until query int false "unix seconds"
|
||||
// @Success 200 {object} LogStatsResponse
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=LogStatsResponse}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /v1/logs/stats [get]
|
||||
func (h *MasterHandler) GetSelfLogStats(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -61,6 +62,7 @@ func TestMaster_ListSelfLogs_FiltersByMaster(t *testing.T) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/v1/logs", withMaster(mh.ListSelfLogs))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -70,9 +72,7 @@ func TestMaster_ListSelfLogs_FiltersByMaster(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp ListMasterLogsResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
env := decodeEnvelope(t, rr, &resp)
|
||||
if resp.Total != 1 || len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got total=%d len=%d body=%s", resp.Total, len(resp.Items), rr.Body.String())
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func TestMaster_ListSelfLogs_FiltersByMaster(t *testing.T) {
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
||||
if err := json.Unmarshal(env.Data, &raw); err != nil {
|
||||
t.Fatalf("unmarshal raw: %v", err)
|
||||
}
|
||||
items, ok := raw["items"].([]any)
|
||||
@@ -141,6 +141,7 @@ func TestAdmin_DeleteLogs_BeforeFilters(t *testing.T) {
|
||||
|
||||
h := &Handler{db: db, logDB: db}
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.DELETE("/admin/logs", h.DeleteLogs)
|
||||
|
||||
body := []byte(fmt.Sprintf(`{"before":"%s","key_id":1,"model":"m1"}`, cutoff.Format(time.RFC3339)))
|
||||
@@ -152,9 +153,7 @@ func TestAdmin_DeleteLogs_BeforeFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp DeleteLogsResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &resp)
|
||||
if resp.DeletedCount != 1 {
|
||||
t.Fatalf("expected deleted_count=1, got %d", resp.DeletedCount)
|
||||
}
|
||||
@@ -182,6 +181,7 @@ func TestAdmin_DeleteLogs_RequiresBefore(t *testing.T) {
|
||||
|
||||
h := &Handler{db: db, logDB: db}
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.DELETE("/admin/logs", h.DeleteLogs)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/admin/logs", bytes.NewReader([]byte(`{}`)))
|
||||
@@ -217,6 +217,7 @@ func TestAdmin_ListLogs_IncludesRequestBody(t *testing.T) {
|
||||
|
||||
h := &Handler{db: db, logDB: db}
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/admin/logs", h.ListLogs)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
|
||||
@@ -227,9 +228,7 @@ func TestAdmin_ListLogs_IncludesRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp ListLogsResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &resp)
|
||||
if resp.Total != 1 || len(resp.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got total=%d len=%d", resp.Total, len(resp.Items))
|
||||
}
|
||||
@@ -264,6 +263,7 @@ func TestLogStats_GroupByModel(t *testing.T) {
|
||||
|
||||
h := &Handler{db: db, logDB: db}
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/admin/logs/stats", h.LogStats)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/logs/stats?group_by=model", nil)
|
||||
@@ -274,9 +274,7 @@ func TestLogStats_GroupByModel(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp GroupedStatsResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &resp)
|
||||
if len(resp.Items) != 2 {
|
||||
t.Fatalf("expected 2 groups, got %d: %+v", len(resp.Items), resp.Items)
|
||||
}
|
||||
@@ -324,6 +322,7 @@ func TestLogStats_DefaultBehavior(t *testing.T) {
|
||||
|
||||
h := &Handler{db: db, logDB: db}
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/admin/logs/stats", h.LogStats)
|
||||
|
||||
// Without group_by, should return aggregated stats
|
||||
@@ -335,9 +334,7 @@ func TestLogStats_DefaultBehavior(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp LogStatsResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &resp)
|
||||
if resp.Total != 2 {
|
||||
t.Fatalf("expected total=2, got %d", resp.Total)
|
||||
}
|
||||
@@ -452,6 +449,7 @@ func TestTrafficChart_MinuteGranularityValidation(t *testing.T) {
|
||||
|
||||
h := &Handler{db: db, logDB: db}
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/admin/logs/stats/traffic-chart", h.GetTrafficChart)
|
||||
|
||||
tests := []struct {
|
||||
@@ -503,8 +501,9 @@ func TestTrafficChart_MinuteGranularityValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
env := decodeEnvelope(t, rr, &resp)
|
||||
if env.Message != tt.wantError {
|
||||
t.Fatalf("expected message=%q, got %q", tt.wantError, env.Message)
|
||||
}
|
||||
if errMsg, ok := resp["error"].(string); !ok || errMsg != tt.wantError {
|
||||
t.Fatalf("expected error=%q, got %v", tt.wantError, resp["error"])
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Success 200 {object} service.LogWebhookConfig
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=service.LogWebhookConfig}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/logs/webhook [get]
|
||||
func (h *Handler) GetLogWebhookConfig(c *gin.Context) {
|
||||
if h == nil || h.logWebhook == nil {
|
||||
@@ -37,9 +37,9 @@ func (h *Handler) GetLogWebhookConfig(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param request body service.LogWebhookConfig true "Webhook config"
|
||||
// @Success 200 {object} service.LogWebhookConfig
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=service.LogWebhookConfig}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/logs/webhook [put]
|
||||
func (h *Handler) UpdateLogWebhookConfig(c *gin.Context) {
|
||||
if h == nil || h.logWebhook == nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -40,6 +41,7 @@ func TestLogWebhookConfigCRUD(t *testing.T) {
|
||||
h, _ := newTestHandlerWithWebhook(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/admin/logs/webhook", h.GetLogWebhookConfig)
|
||||
r.PUT("/admin/logs/webhook", h.UpdateLogWebhookConfig)
|
||||
|
||||
@@ -70,8 +72,9 @@ func TestLogWebhookConfigCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
var got service.LogWebhookConfig
|
||||
if err := json.Unmarshal(getRR.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
env := decodeEnvelope(t, getRR, &got)
|
||||
if env.Code != "ok" {
|
||||
t.Fatalf("expected code=ok, got %q", env.Code)
|
||||
}
|
||||
if !got.Enabled || got.URL == "" || got.Threshold != 3 {
|
||||
t.Fatalf("unexpected webhook config: %+v", got)
|
||||
@@ -85,6 +88,7 @@ func TestLogWebhookConfigDisableClears(t *testing.T) {
|
||||
h, mr := newTestHandlerWithWebhook(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.PUT("/admin/logs/webhook", h.UpdateLogWebhookConfig)
|
||||
|
||||
seed := service.LogWebhookConfig{Enabled: true, URL: "https://example.com"}
|
||||
|
||||
@@ -58,11 +58,11 @@ type IssueChildKeyRequest struct {
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Param request body IssueChildKeyRequest true "Key Request"
|
||||
// @Success 201 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 403 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 201 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 403 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /v1/tokens [post]
|
||||
func (h *MasterHandler) IssueChildKey(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
@@ -145,8 +145,8 @@ func (h *MasterHandler) IssueChildKey(c *gin.Context) {
|
||||
// @Tags master
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Success 200 {object} MasterView
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=MasterView}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /v1/self [get]
|
||||
func (h *MasterHandler) GetSelf(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
@@ -219,9 +219,9 @@ func toTokenView(k model.Key) TokenView {
|
||||
// @Param page query int false "page (1-based)"
|
||||
// @Param limit query int false "limit (default 50, max 200)"
|
||||
// @Param search query string false "search by group/scopes/namespaces/status"
|
||||
// @Success 200 {array} TokenView
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=[]TokenView}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /v1/tokens [get]
|
||||
func (h *MasterHandler) ListTokens(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
@@ -254,11 +254,11 @@ func (h *MasterHandler) ListTokens(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Param id path int true "Token ID"
|
||||
// @Success 200 {object} TokenView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=TokenView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /v1/tokens/{id} [get]
|
||||
func (h *MasterHandler) GetToken(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
@@ -302,11 +302,11 @@ type UpdateTokenRequest struct {
|
||||
// @Security MasterAuth
|
||||
// @Param id path int true "Token ID"
|
||||
// @Param request body UpdateTokenRequest true "Update payload"
|
||||
// @Success 200 {object} TokenView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=TokenView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /v1/tokens/{id} [put]
|
||||
func (h *MasterHandler) UpdateToken(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
@@ -398,11 +398,11 @@ func (h *MasterHandler) UpdateToken(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Param id path int true "Token ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /v1/tokens/{id} [delete]
|
||||
func (h *MasterHandler) DeleteToken(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
|
||||
@@ -2,13 +2,13 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -53,6 +53,7 @@ func TestMaster_ListTokens_AndUpdateToken(t *testing.T) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/v1/tokens", withMaster(h.ListTokens))
|
||||
r.PUT("/v1/tokens/:id", withMaster(h.UpdateToken))
|
||||
|
||||
@@ -63,9 +64,7 @@ func TestMaster_ListTokens_AndUpdateToken(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var list []TokenView
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("unmarshal list: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &list)
|
||||
if len(list) != 1 || list[0].ID != k.ID {
|
||||
t.Fatalf("unexpected list: %+v", list)
|
||||
}
|
||||
@@ -79,9 +78,7 @@ func TestMaster_ListTokens_AndUpdateToken(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var updated TokenView
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("unmarshal updated: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &updated)
|
||||
if updated.Status != "suspended" {
|
||||
t.Fatalf("expected status suspended, got %+v", updated)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -40,6 +41,7 @@ func TestCreateModel_DefaultsKindChat_AndWritesModelsMeta(t *testing.T) {
|
||||
h, _, mr := newTestHandlerWithRedis(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.POST("/admin/models", h.CreateModel)
|
||||
|
||||
reqBody := map[string]any{
|
||||
@@ -86,6 +88,7 @@ func TestCreateModel_InvalidKind_Returns400(t *testing.T) {
|
||||
h, _, _ := newTestHandlerWithRedis(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.POST("/admin/models", h.CreateModel)
|
||||
|
||||
reqBody := map[string]any{
|
||||
@@ -108,6 +111,7 @@ func TestDeleteModel_RemovesMeta(t *testing.T) {
|
||||
h, db, mr := newTestHandlerWithRedis(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.POST("/admin/models", h.CreateModel)
|
||||
r.DELETE("/admin/models/:id", h.DeleteModel)
|
||||
|
||||
@@ -125,9 +129,7 @@ func TestDeleteModel_RemovesMeta(t *testing.T) {
|
||||
t.Fatalf("expected 201, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var created model.Model
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &created)
|
||||
|
||||
delReq := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/admin/models/%d", created.ID), nil)
|
||||
delRec := httptest.NewRecorder()
|
||||
@@ -152,6 +154,7 @@ func TestBatchModels_Delete(t *testing.T) {
|
||||
h, db, mr := newTestHandlerWithRedis(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.POST("/admin/models", h.CreateModel)
|
||||
r.POST("/admin/models/batch", h.BatchModels)
|
||||
|
||||
@@ -166,9 +169,7 @@ func TestBatchModels_Delete(t *testing.T) {
|
||||
t.Fatalf("expected 201, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var created model.Model
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &created)
|
||||
return created.ID
|
||||
}
|
||||
|
||||
@@ -225,6 +226,7 @@ func TestBatchBindings_Status(t *testing.T) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.POST("/admin/bindings/batch", h.BatchBindings)
|
||||
|
||||
payload := map[string]any{
|
||||
|
||||
@@ -22,8 +22,8 @@ func NewModelRegistryHandler(reg *service.ModelRegistryService) *ModelRegistryHa
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Success 200 {object} service.ModelRegistryStatus
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=service.ModelRegistryStatus}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/model-registry/status [get]
|
||||
func (h *ModelRegistryHandler) GetStatus(c *gin.Context) {
|
||||
if h == nil || h.reg == nil {
|
||||
@@ -50,9 +50,9 @@ type refreshModelRegistryRequest struct {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param body body refreshModelRegistryRequest false "optional override ref"
|
||||
// @Success 200 {object} service.ModelRegistryCheckResult
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=service.ModelRegistryCheckResult}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/model-registry/check [post]
|
||||
func (h *ModelRegistryHandler) Check(c *gin.Context) {
|
||||
if h == nil || h.reg == nil {
|
||||
@@ -78,9 +78,9 @@ func (h *ModelRegistryHandler) Check(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param body body refreshModelRegistryRequest false "optional override ref"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/model-registry/refresh [post]
|
||||
func (h *ModelRegistryHandler) Refresh(c *gin.Context) {
|
||||
if h == nil || h.reg == nil {
|
||||
@@ -103,8 +103,8 @@ func (h *ModelRegistryHandler) Refresh(c *gin.Context) {
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/model-registry/rollback [post]
|
||||
func (h *ModelRegistryHandler) Rollback(c *gin.Context) {
|
||||
if h == nil || h.reg == nil {
|
||||
|
||||
@@ -22,9 +22,9 @@ type NamespaceRequest struct {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param namespace body NamespaceRequest true "Namespace payload"
|
||||
// @Success 201 {object} model.Namespace
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 201 {object} ResponseEnvelope{data=model.Namespace}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/namespaces [post]
|
||||
func (h *Handler) CreateNamespace(c *gin.Context) {
|
||||
var req NamespaceRequest
|
||||
@@ -64,8 +64,8 @@ func (h *Handler) CreateNamespace(c *gin.Context) {
|
||||
// @Param page query int false "page (1-based)"
|
||||
// @Param limit query int false "limit (default 50, max 200)"
|
||||
// @Param search query string false "search by name/description"
|
||||
// @Success 200 {array} model.Namespace
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=[]model.Namespace}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/namespaces [get]
|
||||
func (h *Handler) ListNamespaces(c *gin.Context) {
|
||||
var out []model.Namespace
|
||||
@@ -87,10 +87,10 @@ func (h *Handler) ListNamespaces(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Namespace ID"
|
||||
// @Success 200 {object} model.Namespace
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=model.Namespace}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/namespaces/{id} [get]
|
||||
func (h *Handler) GetNamespace(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -120,10 +120,10 @@ type UpdateNamespaceRequest struct {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Namespace ID"
|
||||
// @Param namespace body UpdateNamespaceRequest true "Update payload"
|
||||
// @Success 200 {object} model.Namespace
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=model.Namespace}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/namespaces/{id} [put]
|
||||
func (h *Handler) UpdateNamespace(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -185,10 +185,10 @@ func (h *Handler) UpdateNamespace(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Namespace ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/namespaces/{id} [delete]
|
||||
func (h *Handler) DeleteNamespace(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
|
||||
@@ -2,13 +2,13 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -64,6 +64,7 @@ func TestNamespaceCRUD_DeleteCleansBindings(t *testing.T) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.POST("/admin/namespaces", h.CreateNamespace)
|
||||
r.DELETE("/admin/namespaces/:id", h.DeleteNamespace)
|
||||
|
||||
@@ -77,9 +78,7 @@ func TestNamespaceCRUD_DeleteCleansBindings(t *testing.T) {
|
||||
t.Fatalf("expected 201, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var created model.Namespace
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &created)
|
||||
|
||||
delReq := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/admin/namespaces/%d", created.ID), nil)
|
||||
delRec := httptest.NewRecorder()
|
||||
|
||||
@@ -48,8 +48,8 @@ func toOperationLogView(l model.OperationLog) OperationLogView {
|
||||
// @Param page query int false "page (1-based)"
|
||||
// @Param limit query int false "limit (default 50, max 200)"
|
||||
// @Param search query string false "search by actor/method/path"
|
||||
// @Success 200 {array} OperationLogView
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=[]OperationLogView}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/operation-logs [get]
|
||||
func (h *AdminHandler) ListOperationLogs(c *gin.Context) {
|
||||
var rows []model.OperationLog
|
||||
|
||||
@@ -19,9 +19,9 @@ import (
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param group body dto.ProviderGroupDTO true "Provider group payload"
|
||||
// @Success 201 {object} model.ProviderGroup
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 201 {object} ResponseEnvelope{data=model.ProviderGroup}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/provider-groups [post]
|
||||
func (h *Handler) CreateProviderGroup(c *gin.Context) {
|
||||
var req dto.ProviderGroupDTO
|
||||
@@ -78,8 +78,8 @@ func (h *Handler) CreateProviderGroup(c *gin.Context) {
|
||||
// @Param page query int false "page (1-based)"
|
||||
// @Param limit query int false "limit (default 50, max 200)"
|
||||
// @Param search query string false "search by name/type"
|
||||
// @Success 200 {array} model.ProviderGroup
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=[]model.ProviderGroup}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/provider-groups [get]
|
||||
func (h *Handler) ListProviderGroups(c *gin.Context) {
|
||||
var groups []model.ProviderGroup
|
||||
@@ -101,10 +101,10 @@ func (h *Handler) ListProviderGroups(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "ProviderGroup ID"
|
||||
// @Success 200 {object} model.ProviderGroup
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=model.ProviderGroup}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/provider-groups/{id} [get]
|
||||
func (h *Handler) GetProviderGroup(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
@@ -128,10 +128,10 @@ func (h *Handler) GetProviderGroup(c *gin.Context) {
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "ProviderGroup ID"
|
||||
// @Param group body dto.ProviderGroupDTO true "Provider group payload"
|
||||
// @Success 200 {object} model.ProviderGroup
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=model.ProviderGroup}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/provider-groups/{id} [put]
|
||||
func (h *Handler) UpdateProviderGroup(c *gin.Context) {
|
||||
idParam := c.Param("id")
|
||||
@@ -221,10 +221,10 @@ func (h *Handler) UpdateProviderGroup(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "ProviderGroup ID"
|
||||
// @Success 200 {object} gin.H
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/provider-groups/{id} [delete]
|
||||
func (h *Handler) DeleteProviderGroup(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
|
||||
@@ -44,10 +44,10 @@ func toMasterRealtimeView(stats service.MasterRealtimeSnapshot) *MasterRealtimeV
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Param id path int true "Master ID"
|
||||
// @Success 200 {object} MasterRealtimeView
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 404 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=MasterRealtimeView}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 404 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/masters/{id}/realtime [get]
|
||||
func (h *AdminHandler) GetMasterRealtime(c *gin.Context) {
|
||||
idRaw := strings.TrimSpace(c.Param("id"))
|
||||
@@ -87,9 +87,9 @@ func (h *AdminHandler) GetMasterRealtime(c *gin.Context) {
|
||||
// @Tags master
|
||||
// @Produce json
|
||||
// @Security MasterAuth
|
||||
// @Success 200 {object} MasterRealtimeView
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=MasterRealtimeView}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /v1/realtime [get]
|
||||
func (h *MasterHandler) GetSelfRealtime(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
@@ -135,8 +135,8 @@ type MasterRealtimeSummaryView struct {
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security AdminAuth
|
||||
// @Success 200 {object} SystemRealtimeView
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=SystemRealtimeView}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/realtime [get]
|
||||
func (h *AdminHandler) GetAdminRealtime(c *gin.Context) {
|
||||
if h.statsService == nil {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -48,6 +48,7 @@ func TestAdminMasterRealtimeEndpoints(t *testing.T) {
|
||||
adminHandler := NewAdminHandler(db, db, masterSvc, syncSvc, statsSvc, nil)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/admin/masters/:id", adminHandler.GetMaster)
|
||||
r.GET("/admin/masters/:id/realtime", adminHandler.GetMasterRealtime)
|
||||
|
||||
@@ -58,9 +59,7 @@ func TestAdminMasterRealtimeEndpoints(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var view MasterView
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil {
|
||||
t.Fatalf("unmarshal master view: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &view)
|
||||
if view.Realtime == nil || view.Realtime.QPS != 2 || view.Realtime.QPSLimit != 5 {
|
||||
t.Fatalf("unexpected realtime in master view: %+v", view.Realtime)
|
||||
}
|
||||
@@ -72,9 +71,7 @@ func TestAdminMasterRealtimeEndpoints(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var realtime MasterRealtimeView
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &realtime); err != nil {
|
||||
t.Fatalf("unmarshal realtime: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &realtime)
|
||||
if realtime.Requests != 12 || realtime.Tokens != 34 || realtime.QPS != 2 {
|
||||
t.Fatalf("unexpected realtime payload: %+v", realtime)
|
||||
}
|
||||
@@ -115,6 +112,7 @@ func TestMasterSelfRealtime(t *testing.T) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/v1/realtime", withMaster(masterHandler.GetSelfRealtime))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -124,9 +122,7 @@ func TestMasterSelfRealtime(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var realtime MasterRealtimeView
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &realtime); err != nil {
|
||||
t.Fatalf("unmarshal realtime: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &realtime)
|
||||
if realtime.Requests != 6 || realtime.Tokens != 8 {
|
||||
t.Fatalf("unexpected realtime payload: %+v", realtime)
|
||||
}
|
||||
|
||||
12
internal/api/response_envelope.go
Normal file
12
internal/api/response_envelope.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package api
|
||||
|
||||
// ResponseEnvelope is the standard wrapper for EZ-API JSON responses.
|
||||
// Code is a stable business code string (e.g., ok, invalid_request, not_found).
|
||||
// Message is empty for success and mirrors the top-level error for failures.
|
||||
// Data holds the original response payload.
|
||||
// swagger:model ResponseEnvelope
|
||||
type ResponseEnvelope struct {
|
||||
Code string `json:"code" example:"ok"`
|
||||
Data any `json:"data" swaggertype:"object"`
|
||||
Message string `json:"message" example:""`
|
||||
}
|
||||
27
internal/api/response_test.go
Normal file
27
internal/api/response_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testEnvelope struct {
|
||||
Code string `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func decodeEnvelope(t *testing.T, rr *httptest.ResponseRecorder, out any) testEnvelope {
|
||||
t.Helper()
|
||||
var env testEnvelope
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v", err)
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(env.Data, out); err != nil {
|
||||
t.Fatalf("decode envelope data: %v", err)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -41,10 +41,10 @@ type MasterUsageStatsResponse struct {
|
||||
// @Param period query string false "today|week|month|all"
|
||||
// @Param since query int false "unix seconds"
|
||||
// @Param until query int false "unix seconds"
|
||||
// @Success 200 {object} MasterUsageStatsResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 401 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=MasterUsageStatsResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 401 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /v1/stats [get]
|
||||
func (h *MasterHandler) GetSelfStats(c *gin.Context) {
|
||||
master, exists := c.Get("master")
|
||||
@@ -147,9 +147,9 @@ type AdminUsageStatsResponse struct {
|
||||
// @Param period query string false "today|week|month|all"
|
||||
// @Param since query int false "unix seconds"
|
||||
// @Param until query int false "unix seconds"
|
||||
// @Success 200 {object} AdminUsageStatsResponse
|
||||
// @Failure 400 {object} gin.H
|
||||
// @Failure 500 {object} gin.H
|
||||
// @Success 200 {object} ResponseEnvelope{data=AdminUsageStatsResponse}
|
||||
// @Failure 400 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Failure 500 {object} ResponseEnvelope{data=gin.H}
|
||||
// @Router /admin/stats [get]
|
||||
func (h *AdminHandler) GetAdminStats(c *gin.Context) {
|
||||
rng, err := parseStatsRange(c)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/ez-api/ez-api/internal/middleware"
|
||||
"github.com/ez-api/ez-api/internal/model"
|
||||
"github.com/ez-api/ez-api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -83,6 +83,7 @@ func TestMasterStats_AggregatesByKeyAndModel(t *testing.T) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/v1/stats", withMaster(h.GetSelfStats))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/stats?period=all", nil)
|
||||
@@ -93,9 +94,7 @@ func TestMasterStats_AggregatesByKeyAndModel(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp MasterUsageStatsResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &resp)
|
||||
if resp.TotalRequests != 2 || resp.TotalTokens != 17 {
|
||||
t.Fatalf("unexpected totals: %+v", resp)
|
||||
}
|
||||
@@ -168,6 +167,7 @@ func TestAdminStats_AggregatesByProvider(t *testing.T) {
|
||||
adminHandler := NewAdminHandler(db, db, masterSvc, syncSvc, statsSvc, nil)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.ResponseEnvelope())
|
||||
r.GET("/admin/stats", adminHandler.GetAdminStats)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/stats?period=all", nil)
|
||||
@@ -178,9 +178,7 @@ func TestAdminStats_AggregatesByProvider(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp AdminUsageStatsResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
decodeEnvelope(t, rr, &resp)
|
||||
if resp.TotalMasters != 2 || resp.ActiveMasters != 1 {
|
||||
t.Fatalf("unexpected master counts: %+v", resp)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ type AboutResponse struct {
|
||||
// @Description Returns public runtime status information without sensitive data
|
||||
// @Tags Public
|
||||
// @Produce json
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Success 200 {object} ResponseEnvelope{data=StatusResponse}
|
||||
// @Router /status [get]
|
||||
func (h *StatusHandler) Status(c *gin.Context) {
|
||||
// Check health status
|
||||
@@ -67,7 +67,7 @@ func (h *StatusHandler) Status(c *gin.Context) {
|
||||
// @Description Returns system metadata for display on an about page
|
||||
// @Tags Public
|
||||
// @Produce json
|
||||
// @Success 200 {object} AboutResponse
|
||||
// @Success 200 {object} ResponseEnvelope{data=AboutResponse}
|
||||
// @Router /about [get]
|
||||
func (h *StatusHandler) About(c *gin.Context) {
|
||||
resp := AboutResponse{
|
||||
|
||||
233
internal/middleware/response_envelope.go
Normal file
233
internal/middleware/response_envelope.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const businessCodeKey = "response_business_code"
|
||||
|
||||
type responseEnvelope struct {
|
||||
Code string `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type envelopeWriter struct {
|
||||
gin.ResponseWriter
|
||||
body bytes.Buffer
|
||||
status int
|
||||
size int
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.wrote = true
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) WriteHeaderNow() {
|
||||
if !w.wrote {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) Write(data []byte) (int, error) {
|
||||
if !w.wrote {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
n, err := w.body.Write(data)
|
||||
w.size += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) WriteString(s string) (int, error) {
|
||||
if !w.wrote {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
n, err := w.body.WriteString(s)
|
||||
w.size += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) Status() int {
|
||||
if w.status == 0 {
|
||||
return http.StatusOK
|
||||
}
|
||||
return w.status
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) Size() int {
|
||||
return w.size
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) Written() bool {
|
||||
return w.wrote
|
||||
}
|
||||
|
||||
// SetBusinessCode sets an explicit business code for the response envelope.
|
||||
func SetBusinessCode(c *gin.Context, code string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return
|
||||
}
|
||||
c.Set(businessCodeKey, code)
|
||||
}
|
||||
|
||||
func ResponseEnvelope() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
originalWriter := c.Writer
|
||||
writer := &envelopeWriter{ResponseWriter: originalWriter}
|
||||
c.Writer = writer
|
||||
|
||||
c.Next()
|
||||
|
||||
status := writer.Status()
|
||||
body := writer.body.Bytes()
|
||||
if !bodyAllowedForStatus(status) || len(body) == 0 {
|
||||
writeStatusOnly(originalWriter, status)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := originalWriter.Header().Get("Content-Type")
|
||||
if !isJSONContentType(contentType) {
|
||||
writeThrough(originalWriter, status, body)
|
||||
return
|
||||
}
|
||||
|
||||
obj, objOK := parseObject(body)
|
||||
if objOK && isEnvelopeObject(obj) {
|
||||
writeThrough(originalWriter, status, body)
|
||||
return
|
||||
}
|
||||
|
||||
code := businessCodeFromContext(c)
|
||||
if code == "" {
|
||||
code = defaultBusinessCode(status)
|
||||
}
|
||||
|
||||
message := ""
|
||||
if status >= http.StatusBadRequest && objOK {
|
||||
if raw, ok := obj["error"]; ok {
|
||||
var msg string
|
||||
if err := json.Unmarshal(raw, &msg); err == nil {
|
||||
message = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
envelope := responseEnvelope{
|
||||
Code: code,
|
||||
Data: json.RawMessage(body),
|
||||
Message: message,
|
||||
}
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
writeThrough(originalWriter, status, body)
|
||||
return
|
||||
}
|
||||
|
||||
originalWriter.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
originalWriter.Header().Del("Content-Length")
|
||||
writeThrough(originalWriter, status, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func businessCodeFromContext(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := c.Get(businessCodeKey)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
code, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(code)
|
||||
}
|
||||
|
||||
func defaultBusinessCode(status int) string {
|
||||
switch {
|
||||
case status >= http.StatusOK && status < http.StatusMultipleChoices:
|
||||
return "ok"
|
||||
case status == http.StatusBadRequest:
|
||||
return "invalid_request"
|
||||
case status == http.StatusUnauthorized:
|
||||
return "unauthorized"
|
||||
case status == http.StatusForbidden:
|
||||
return "forbidden"
|
||||
case status == http.StatusNotFound:
|
||||
return "not_found"
|
||||
case status == http.StatusConflict:
|
||||
return "conflict"
|
||||
case status == http.StatusTooManyRequests:
|
||||
return "rate_limited"
|
||||
case status >= http.StatusBadRequest && status < http.StatusInternalServerError:
|
||||
return "request_error"
|
||||
case status >= http.StatusInternalServerError:
|
||||
return "internal_error"
|
||||
default:
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
|
||||
func bodyAllowedForStatus(status int) bool {
|
||||
switch {
|
||||
case status >= 100 && status <= 199:
|
||||
return false
|
||||
case status == http.StatusNoContent:
|
||||
return false
|
||||
case status == http.StatusNotModified:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isJSONContentType(contentType string) bool {
|
||||
if contentType == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(contentType), "application/json")
|
||||
}
|
||||
|
||||
func parseObject(body []byte) (map[string]json.RawMessage, bool) {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &obj); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return obj, true
|
||||
}
|
||||
|
||||
func isEnvelopeObject(obj map[string]json.RawMessage) bool {
|
||||
if obj == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := obj["code"]; !ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := obj["data"]; !ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := obj["message"]; !ok {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeStatusOnly(w gin.ResponseWriter, status int) {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
|
||||
func writeThrough(w gin.ResponseWriter, status int, body []byte) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
109
internal/middleware/response_envelope_test.go
Normal file
109
internal/middleware/response_envelope_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestResponseEnvelope_DefaultMapping(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(ResponseEnvelope())
|
||||
r.GET("/missing", func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not here"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/missing", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var env struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
if env.Code != "not_found" {
|
||||
t.Fatalf("expected code=not_found, got %q", env.Code)
|
||||
}
|
||||
if env.Message != "not here" {
|
||||
t.Fatalf("expected message 'not here', got %q", env.Message)
|
||||
}
|
||||
if env.Data["error"] != "not here" {
|
||||
t.Fatalf("expected data.error 'not here', got %v", env.Data["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_OverrideBusinessCode(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(ResponseEnvelope())
|
||||
r.GET("/rate-limit", func(c *gin.Context) {
|
||||
SetBusinessCode(c, "quota_exceeded")
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limited"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/rate-limit", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var env struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
if env.Code != "quota_exceeded" {
|
||||
t.Fatalf("expected code=quota_exceeded, got %q", env.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_Idempotent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(ResponseEnvelope())
|
||||
r.GET("/wrapped", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": "ok",
|
||||
"data": gin.H{"id": 1},
|
||||
"message": "",
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/wrapped", 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 env map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
if env["code"] != "ok" {
|
||||
t.Fatalf("expected code=ok, got %v", env["code"])
|
||||
}
|
||||
data, ok := env["data"].(map[string]any)
|
||||
if !ok || data["id"] != float64(1) {
|
||||
t.Fatalf("unexpected data: %+v", env["data"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user