From ed6446e5861c88123c3f8a534b9ad2b610b2971b Mon Sep 17 00:00:00 2001 From: zenfun Date: Wed, 17 Dec 2025 00:37:02 +0800 Subject: [PATCH] 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. --- cmd/server/main.go | 3 + internal/api/binding_handler.go | 160 ++++++++++++++++++++++++++++++++ internal/api/handler.go | 9 ++ internal/dto/binding.go | 13 +++ internal/service/sync.go | 7 +- 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 internal/api/binding_handler.go create mode 100644 internal/dto/binding.go diff --git a/cmd/server/main.go b/cmd/server/main.go index bd949f4..a3b62dd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) } diff --git a/internal/api/binding_handler.go b/internal/api/binding_handler.go new file mode 100644 index 0000000..a5269de --- /dev/null +++ b/internal/api/binding_handler.go @@ -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) +} + diff --git a/internal/api/handler.go b/internal/api/handler.go index a146e6c..3c05982 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -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) } diff --git a/internal/dto/binding.go b/internal/dto/binding.go new file mode 100644 index 0000000..e3b5d15 --- /dev/null +++ b/internal/dto/binding.go @@ -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"` +} + diff --git a/internal/service/sync.go b/internal/service/sync.go index dd5f102..0458c3e 100644 --- a/internal/service/sync.go +++ b/internal/service/sync.go @@ -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{