Files
ez-api/internal/api/binding_handler.go
zenfun 33838b1e2c 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.
2026-01-10 00:15:08 +08:00

267 lines
8.1 KiB
Go

package api
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/ez-api/ez-api/internal/dto"
"github.com/ez-api/ez-api/internal/model"
"github.com/gin-gonic/gin"
)
// CreateBinding godoc
// @Summary Create a new binding
// @Description Create a new (namespace, public_model) binding to a provider group and selector
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param binding body dto.BindingDTO true "Binding Info"
// @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
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ns := strings.TrimSpace(req.Namespace)
pm := strings.TrimSpace(req.PublicModel)
if ns == "" || pm == "" || req.GroupID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "namespace, public_model, and group_id required"})
return
}
if err := h.ensureActiveGroup(req.GroupID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st := strings.TrimSpace(req.Status)
if st == "" {
st = "active"
}
selectorType := strings.TrimSpace(req.SelectorType)
if selectorType == "" {
selectorType = "exact"
}
b := model.Binding{
Namespace: ns,
PublicModel: pm,
GroupID: req.GroupID,
Weight: normalizeWeight(req.Weight),
SelectorType: selectorType,
SelectorValue: strings.TrimSpace(req.SelectorValue),
Status: st,
}
if err := h.db.Create(&b).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create binding", "details": err.Error()})
return
}
if err := h.sync.SyncBindings(h.db); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync bindings", "details": err.Error()})
return
}
c.JSON(http.StatusCreated, b)
}
// ListBindings godoc
// @Summary List bindings
// @Description List all configured bindings
// @Tags admin
// @Produce json
// @Security AdminAuth
// @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 {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
q := h.db.Model(&model.Binding{}).Order("id desc")
query := parseListQuery(c)
q = applyListSearch(q, query.Search, "namespace", "public_model")
q = applyListPagination(q, query)
if err := q.Find(&out).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list bindings", "details": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
// UpdateBinding godoc
// @Summary Update a binding
// @Description Update an existing binding
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param id path int true "Binding ID"
// @Param binding body dto.BindingDTO true "Binding Info"
// @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")
id, err := strconv.Atoi(idParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var existing model.Binding
if err := h.db.First(&existing, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
var req dto.BindingDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if ns := strings.TrimSpace(req.Namespace); ns != "" {
existing.Namespace = ns
}
if pm := strings.TrimSpace(req.PublicModel); pm != "" {
existing.PublicModel = pm
}
if req.GroupID != 0 {
if err := h.ensureActiveGroup(req.GroupID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
existing.GroupID = req.GroupID
}
if req.Weight > 0 {
existing.Weight = normalizeWeight(req.Weight)
}
if st := strings.TrimSpace(req.Status); st != "" {
existing.Status = st
}
if t := strings.TrimSpace(req.SelectorType); t != "" {
existing.SelectorType = t
}
if req.SelectorValue != "" {
existing.SelectorValue = strings.TrimSpace(req.SelectorValue)
}
if err := h.db.Save(&existing).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update binding", "details": err.Error()})
return
}
if err := h.sync.SyncBindings(h.db); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync bindings", "details": err.Error()})
return
}
c.JSON(http.StatusOK, existing)
}
// GetBinding godoc
// @Summary Get a binding
// @Description Get a binding by id
// @Tags admin
// @Produce json
// @Security AdminAuth
// @Param id path int true "Binding ID"
// @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")
id, err := strconv.Atoi(idParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var existing model.Binding
if err := h.db.First(&existing, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
c.JSON(http.StatusOK, existing)
}
// DeleteBinding godoc
// @Summary Delete a binding
// @Description Delete a binding by id and rebuild bindings snapshot
// @Tags admin
// @Produce json
// @Security AdminAuth
// @Param id path int true "Binding ID"
// @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")
id, err := strconv.Atoi(idParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var existing model.Binding
if err := h.db.First(&existing, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
if err := h.db.Delete(&existing).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding", "details": err.Error()})
return
}
if err := h.sync.SyncBindings(h.db); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync bindings", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
func normalizeWeight(weight int) int {
if weight <= 0 {
return 1
}
return weight
}
func (h *Handler) ensureActiveGroup(groupID uint) error {
var group model.ProviderGroup
if err := h.db.First(&group, groupID).Error; err != nil {
return fmt.Errorf("provider group not found")
}
if strings.TrimSpace(group.Status) != "" && strings.TrimSpace(group.Status) != "active" {
return fmt.Errorf("provider group not active")
}
var count int64
if err := h.db.Model(&model.APIKey{}).
Where("group_id = ? AND status = ?", groupID, "active").
Count(&count).Error; err != nil {
return fmt.Errorf("failed to check api keys")
}
if count == 0 {
return fmt.Errorf("provider group has no active api keys")
}
return nil
}