feat(api): wrap JSON responses in envelope

Add response envelope middleware to standardize JSON responses as
`{code,data,message}` with consistent business codes across endpoints.
Update Swagger annotations and tests to reflect the new response shape.

BREAKING CHANGE: API responses are now wrapped in a response envelope; clients must read payloads from `data` and handle `code`/`message` fields.
This commit is contained in:
zenfun
2026-01-10 00:15:08 +08:00
parent f400ffde95
commit 33838b1e2c
40 changed files with 771 additions and 371 deletions

View File

@@ -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")

View File

@@ -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"))

View File

@@ -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"])

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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")

View File

@@ -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"])

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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"])

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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")

View File

@@ -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"])

View File

@@ -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 {

View File

@@ -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"}

View File

@@ -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")

View File

@@ -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)
}

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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()

View File

@@ -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

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -0,0 +1,12 @@
package api
// ResponseEnvelope is the standard wrapper for EZ-API JSON responses.
// Code is a stable business code string (e.g., ok, invalid_request, not_found).
// Message is empty for success and mirrors the top-level error for failures.
// Data holds the original response payload.
// swagger:model ResponseEnvelope
type ResponseEnvelope struct {
Code string `json:"code" example:"ok"`
Data any `json:"data" swaggertype:"object"`
Message string `json:"message" example:""`
}

View File

@@ -0,0 +1,27 @@
package api
import (
"encoding/json"
"net/http/httptest"
"testing"
)
type testEnvelope struct {
Code string `json:"code"`
Data json.RawMessage `json:"data"`
Message string `json:"message"`
}
func decodeEnvelope(t *testing.T, rr *httptest.ResponseRecorder, out any) testEnvelope {
t.Helper()
var env testEnvelope
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if out != nil {
if err := json.Unmarshal(env.Data, out); err != nil {
t.Fatalf("decode envelope data: %v", err)
}
}
return env
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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{