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{