feat(api): add admin endpoints for binding management

Implement handlers for creating, listing, and updating model bindings.
Register new routes in the admin server group and add DTO definitions.
Update provider handlers to trigger binding synchronization on changes
to ensure upstream mappings remain current.
This commit is contained in:
zenfun
2025-12-17 00:37:02 +08:00
parent 2e3b471533
commit ed6446e586
5 changed files with 190 additions and 2 deletions

View File

@@ -167,6 +167,9 @@ func main() {
adminGroup.POST("/models", handler.CreateModel)
adminGroup.GET("/models", handler.ListModels)
adminGroup.PUT("/models/:id", handler.UpdateModel)
adminGroup.POST("/bindings", handler.CreateBinding)
adminGroup.GET("/bindings", handler.ListBindings)
adminGroup.PUT("/bindings/:id", handler.UpdateBinding)
adminGroup.POST("/sync/snapshot", handler.SyncSnapshot)
}

View File

@@ -0,0 +1,160 @@
package api
import (
"net/http"
"strconv"
"strings"
"github.com/ez-api/ez-api/internal/dto"
"github.com/ez-api/ez-api/internal/model"
groupx "github.com/ez-api/foundation/group"
"github.com/gin-gonic/gin"
)
// CreateBinding godoc
// @Summary Create a new binding
// @Description Create a new (namespace, public_model) binding to a route group and selector
// @Tags admin
// @Accept json
// @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
// @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 == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "namespace and public_model required"})
return
}
rg := groupx.Normalize(req.RouteGroup)
if strings.TrimSpace(rg) == "" {
rg = "default"
}
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,
RouteGroup: rg,
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
// @Success 200 {array} model.Binding
// @Failure 500 {object} gin.H
// @Router /admin/bindings [get]
func (h *Handler) ListBindings(c *gin.Context) {
var out []model.Binding
if err := h.db.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} model.Binding
// @Failure 400 {object} gin.H
// @Failure 404 {object} gin.H
// @Failure 500 {object} 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 rg := strings.TrimSpace(req.RouteGroup); rg != "" {
existing.RouteGroup = groupx.Normalize(rg)
}
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)
}

View File

@@ -89,6 +89,11 @@ func (h *Handler) CreateProvider(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync provider", "details": err.Error()})
return
}
// Provider model list changes can affect binding upstream mappings; rebuild bindings snapshot.
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, provider)
}
@@ -196,6 +201,10 @@ func (h *Handler) UpdateProvider(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync provider", "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)
}

13
internal/dto/binding.go Normal file
View File

@@ -0,0 +1,13 @@
package dto
// BindingDTO defines inbound payload for binding creation/update.
// It maps "(namespace, public_model)" to a RouteGroup and an upstream selector.
type BindingDTO struct {
Namespace string `json:"namespace"`
PublicModel string `json:"public_model"`
RouteGroup string `json:"route_group"`
SelectorType string `json:"selector_type"`
SelectorValue string `json:"selector_value"`
Status string `json:"status"`
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/ez-api/ez-api/internal/model"
groupx "github.com/ez-api/foundation/group"
"github.com/ez-api/foundation/jsoncodec"
"github.com/ez-api/foundation/routing"
"github.com/ez-api/foundation/tokenhash"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
@@ -407,9 +408,11 @@ func (s *SyncService) writeBindingsSnapshot(ctx context.Context, pipe redis.Pipe
}
key := ns + "." + pm
if err := s.hsetJSON(ctx, "config:bindings", key, snap); err != nil {
return err
payload, err := jsoncodec.Marshal(snap)
if err != nil {
return fmt.Errorf("marshal config:bindings:%s: %w", key, err)
}
pipe.HSet(ctx, "config:bindings", key, payload)
}
meta := map[string]string{