From 33838b1e2c04477ff5a40be46793947936714041 Mon Sep 17 00:00:00 2001 From: zenfun Date: Sat, 10 Jan 2026 00:15:08 +0800 Subject: [PATCH] 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. --- cmd/server/main.go | 2 + internal/api/access_handler.go | 32 +-- internal/api/admin_handler.go | 52 ++-- internal/api/admin_issue_key_test.go | 11 +- internal/api/alert_handler.go | 54 ++-- internal/api/alert_handler_test.go | 23 +- internal/api/api_key_handler.go | 34 +-- internal/api/apikey_stats_handler.go | 4 +- internal/api/apikey_stats_handler_test.go | 8 +- internal/api/auth_handler.go | 4 +- internal/api/auth_handler_test.go | 22 +- internal/api/batch_handler.go | 24 +- internal/api/binding_handler.go | 34 +-- internal/api/dashboard_handler.go | 6 +- internal/api/feature_handler.go | 10 +- internal/api/feature_handler_test.go | 30 ++- internal/api/handler.go | 34 +-- internal/api/internal_handler.go | 18 +- internal/api/ip_ban_handler.go | 32 +-- internal/api/log_handler.go | 36 +-- internal/api/log_handler_test.go | 35 ++- internal/api/log_webhook_handler.go | 10 +- internal/api/log_webhook_handler_test.go | 8 +- internal/api/master_handler.go | 50 ++-- internal/api/master_tokens_handler_test.go | 11 +- internal/api/model_handler_test.go | 14 +- internal/api/model_registry_handler.go | 20 +- internal/api/namespace_handler.go | 34 +-- internal/api/namespace_handler_test.go | 7 +- internal/api/operation_log_handler.go | 4 +- internal/api/provider_group_handler.go | 34 +-- internal/api/realtime_handler.go | 18 +- internal/api/realtime_handler_test.go | 16 +- internal/api/response_envelope.go | 12 + internal/api/response_test.go | 27 ++ internal/api/stats_handler.go | 14 +- internal/api/stats_handler_test.go | 12 +- internal/api/status_handler.go | 4 +- internal/middleware/response_envelope.go | 233 ++++++++++++++++++ internal/middleware/response_envelope_test.go | 109 ++++++++ 40 files changed, 771 insertions(+), 371 deletions(-) create mode 100644 internal/api/response_envelope.go create mode 100644 internal/api/response_test.go create mode 100644 internal/middleware/response_envelope.go create mode 100644 internal/middleware/response_envelope_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 310fb5c..7d85e55 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -292,6 +292,8 @@ func main() { c.Next() }) + r.Use(middleware.ResponseEnvelope()) + // 动态设置 Swagger Host if cfg.Server.SwaggerHost != "" { docs.SwaggerInfo.Host = cfg.Server.SwaggerHost diff --git a/internal/api/access_handler.go b/internal/api/access_handler.go index 18fc4c1..98f559a 100644 --- a/internal/api/access_handler.go +++ b/internal/api/access_handler.go @@ -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") diff --git a/internal/api/admin_handler.go b/internal/api/admin_handler.go index d57b2b7..78c66b2 100644 --- a/internal/api/admin_handler.go +++ b/internal/api/admin_handler.go @@ -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")) diff --git a/internal/api/admin_issue_key_test.go b/internal/api/admin_issue_key_test.go index 69ad058..d9d43e2 100644 --- a/internal/api/admin_issue_key_test.go +++ b/internal/api/admin_issue_key_test.go @@ -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"]) diff --git a/internal/api/alert_handler.go b/internal/api/alert_handler.go index 1b3fcee..7764d88 100644 --- a/internal/api/alert_handler.go +++ b/internal/api/alert_handler.go @@ -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 diff --git a/internal/api/alert_handler_test.go b/internal/api/alert_handler_test.go index 3c4cc86..097ddd8 100644 --- a/internal/api/alert_handler_test.go +++ b/internal/api/alert_handler_test.go @@ -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) diff --git a/internal/api/api_key_handler.go b/internal/api/api_key_handler.go index 302dde1..f40f008 100644 --- a/internal/api/api_key_handler.go +++ b/internal/api/api_key_handler.go @@ -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") diff --git a/internal/api/apikey_stats_handler.go b/internal/api/apikey_stats_handler.go index 6c64b9d..cb8dc3c 100644 --- a/internal/api/apikey_stats_handler.go +++ b/internal/api/apikey_stats_handler.go @@ -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 { diff --git a/internal/api/apikey_stats_handler_test.go b/internal/api/apikey_stats_handler_test.go index 97d18e2..747deb2 100644 --- a/internal/api/apikey_stats_handler_test.go +++ b/internal/api/apikey_stats_handler_test.go @@ -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) diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go index 83d9560..c9cfb68 100644 --- a/internal/api/auth_handler.go +++ b/internal/api/auth_handler.go @@ -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") diff --git a/internal/api/auth_handler_test.go b/internal/api/auth_handler_test.go index cd46967..b320cdd 100644 --- a/internal/api/auth_handler_test.go +++ b/internal/api/auth_handler_test.go @@ -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"]) diff --git a/internal/api/batch_handler.go b/internal/api/batch_handler.go index ef0050c..48c41a6 100644 --- a/internal/api/batch_handler.go +++ b/internal/api/batch_handler.go @@ -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 diff --git a/internal/api/binding_handler.go b/internal/api/binding_handler.go index 633213d..f2ef5cb 100644 --- a/internal/api/binding_handler.go +++ b/internal/api/binding_handler.go @@ -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") diff --git a/internal/api/dashboard_handler.go b/internal/api/dashboard_handler.go index da8ac6e..e38bd20 100644 --- a/internal/api/dashboard_handler.go +++ b/internal/api/dashboard_handler.go @@ -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) diff --git a/internal/api/feature_handler.go b/internal/api/feature_handler.go index 7c452f5..6e71ca5 100644 --- a/internal/api/feature_handler.go +++ b/internal/api/feature_handler.go @@ -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 { diff --git a/internal/api/feature_handler_test.go b/internal/api/feature_handler_test.go index 0b02737..7d607bc 100644 --- a/internal/api/feature_handler_test.go +++ b/internal/api/feature_handler_test.go @@ -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"]) diff --git a/internal/api/handler.go b/internal/api/handler.go index a9226b0..7536a82 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -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 diff --git a/internal/api/internal_handler.go b/internal/api/internal_handler.go index 1e7d016..c1f76e2 100644 --- a/internal/api/internal_handler.go +++ b/internal/api/internal_handler.go @@ -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 { diff --git a/internal/api/ip_ban_handler.go b/internal/api/ip_ban_handler.go index 733e2b6..1df7acd 100644 --- a/internal/api/ip_ban_handler.go +++ b/internal/api/ip_ban_handler.go @@ -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) diff --git a/internal/api/log_handler.go b/internal/api/log_handler.go index 41bc6b8..2e26e37 100644 --- a/internal/api/log_handler.go +++ b/internal/api/log_handler.go @@ -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") diff --git a/internal/api/log_handler_test.go b/internal/api/log_handler_test.go index 9607220..2b9306f 100644 --- a/internal/api/log_handler_test.go +++ b/internal/api/log_handler_test.go @@ -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"]) diff --git a/internal/api/log_webhook_handler.go b/internal/api/log_webhook_handler.go index 91449a6..d433ab8 100644 --- a/internal/api/log_webhook_handler.go +++ b/internal/api/log_webhook_handler.go @@ -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 { diff --git a/internal/api/log_webhook_handler_test.go b/internal/api/log_webhook_handler_test.go index bdc3a8c..1ddd4d6 100644 --- a/internal/api/log_webhook_handler_test.go +++ b/internal/api/log_webhook_handler_test.go @@ -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"} diff --git a/internal/api/master_handler.go b/internal/api/master_handler.go index 991d048..4da1a06 100644 --- a/internal/api/master_handler.go +++ b/internal/api/master_handler.go @@ -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") diff --git a/internal/api/master_tokens_handler_test.go b/internal/api/master_tokens_handler_test.go index 77f92d3..a5e3921 100644 --- a/internal/api/master_tokens_handler_test.go +++ b/internal/api/master_tokens_handler_test.go @@ -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) } diff --git a/internal/api/model_handler_test.go b/internal/api/model_handler_test.go index 0c96c57..4f3792e 100644 --- a/internal/api/model_handler_test.go +++ b/internal/api/model_handler_test.go @@ -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{ diff --git a/internal/api/model_registry_handler.go b/internal/api/model_registry_handler.go index 0609118..14caf72 100644 --- a/internal/api/model_registry_handler.go +++ b/internal/api/model_registry_handler.go @@ -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 { diff --git a/internal/api/namespace_handler.go b/internal/api/namespace_handler.go index 2412e6a..8b8852b 100644 --- a/internal/api/namespace_handler.go +++ b/internal/api/namespace_handler.go @@ -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") diff --git a/internal/api/namespace_handler_test.go b/internal/api/namespace_handler_test.go index c085ad2..da5dffb 100644 --- a/internal/api/namespace_handler_test.go +++ b/internal/api/namespace_handler_test.go @@ -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() diff --git a/internal/api/operation_log_handler.go b/internal/api/operation_log_handler.go index 09b0d85..9415168 100644 --- a/internal/api/operation_log_handler.go +++ b/internal/api/operation_log_handler.go @@ -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 diff --git a/internal/api/provider_group_handler.go b/internal/api/provider_group_handler.go index a432a11..3ebf8c4 100644 --- a/internal/api/provider_group_handler.go +++ b/internal/api/provider_group_handler.go @@ -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") diff --git a/internal/api/realtime_handler.go b/internal/api/realtime_handler.go index 0ff59cf..970653e 100644 --- a/internal/api/realtime_handler.go +++ b/internal/api/realtime_handler.go @@ -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 { diff --git a/internal/api/realtime_handler_test.go b/internal/api/realtime_handler_test.go index 4be3789..8768c9e 100644 --- a/internal/api/realtime_handler_test.go +++ b/internal/api/realtime_handler_test.go @@ -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) } diff --git a/internal/api/response_envelope.go b/internal/api/response_envelope.go new file mode 100644 index 0000000..064fe00 --- /dev/null +++ b/internal/api/response_envelope.go @@ -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:""` +} diff --git a/internal/api/response_test.go b/internal/api/response_test.go new file mode 100644 index 0000000..68112f3 --- /dev/null +++ b/internal/api/response_test.go @@ -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 +} diff --git a/internal/api/stats_handler.go b/internal/api/stats_handler.go index 59ec29a..afa60a0 100644 --- a/internal/api/stats_handler.go +++ b/internal/api/stats_handler.go @@ -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) diff --git a/internal/api/stats_handler_test.go b/internal/api/stats_handler_test.go index d79bd72..80ae396 100644 --- a/internal/api/stats_handler_test.go +++ b/internal/api/stats_handler_test.go @@ -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) } diff --git a/internal/api/status_handler.go b/internal/api/status_handler.go index 3bbc7ed..b383f90 100644 --- a/internal/api/status_handler.go +++ b/internal/api/status_handler.go @@ -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{ diff --git a/internal/middleware/response_envelope.go b/internal/middleware/response_envelope.go new file mode 100644 index 0000000..4012529 --- /dev/null +++ b/internal/middleware/response_envelope.go @@ -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) +} diff --git a/internal/middleware/response_envelope_test.go b/internal/middleware/response_envelope_test.go new file mode 100644 index 0000000..4d19c1d --- /dev/null +++ b/internal/middleware/response_envelope_test.go @@ -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"]) + } +}