package api import ( "encoding/json" "fmt" "io" "net/http" "strings" "github.com/bsf/orchard/internal/models" "github.com/bsf/orchard/internal/storage" "github.com/gin-gonic/gin" "go.uber.org/zap" ) type Handler struct { db *storage.Database s3 *storage.S3Storage logger *zap.Logger } func NewHandler(db *storage.Database, s3 *storage.S3Storage, logger *zap.Logger) *Handler { return &Handler{ db: db, s3: s3, logger: logger, } } // Health check endpoint func (h *Handler) Health(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "healthy"}) } // Grove handlers func (h *Handler) CreateGrove(c *gin.Context) { var grove models.Grove if err := c.ShouldBindJSON(&grove); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } grove.CreatedBy = c.GetString("user_id") if err := h.db.CreateGrove(c.Request.Context(), &grove); err != nil { h.logger.Error("failed to create grove", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create grove"}) return } h.logAudit(c, "create_grove", grove.Name) c.JSON(http.StatusCreated, grove) } func (h *Handler) GetGrove(c *gin.Context) { groveName := c.Param("grove") grove, err := h.db.GetGrove(c.Request.Context(), groveName) if err != nil { h.logger.Error("failed to get grove", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get grove"}) return } if grove == nil { c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) return } c.JSON(http.StatusOK, grove) } func (h *Handler) ListGroves(c *gin.Context) { userID := c.GetString("user_id") groves, err := h.db.ListGroves(c.Request.Context(), userID) if err != nil { h.logger.Error("failed to list groves", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groves"}) return } c.JSON(http.StatusOK, groves) } // Tree handlers func (h *Handler) CreateTree(c *gin.Context) { groveName := c.Param("grove") grove, err := h.db.GetGrove(c.Request.Context(), groveName) if err != nil || grove == nil { c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) return } var tree models.Tree if err := c.ShouldBindJSON(&tree); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tree.GroveID = grove.ID if err := h.db.CreateTree(c.Request.Context(), &tree); err != nil { h.logger.Error("failed to create tree", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create tree"}) return } h.logAudit(c, "create_tree", fmt.Sprintf("%s/%s", groveName, tree.Name)) c.JSON(http.StatusCreated, tree) } func (h *Handler) ListTrees(c *gin.Context) { groveName := c.Param("grove") grove, err := h.db.GetGrove(c.Request.Context(), groveName) if err != nil || grove == nil { c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) return } trees, err := h.db.ListTrees(c.Request.Context(), grove.ID) if err != nil { h.logger.Error("failed to list trees", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list trees"}) return } c.JSON(http.StatusOK, trees) } // Fruit handlers (content-addressable storage) func (h *Handler) Cultivate(c *gin.Context) { groveName := c.Param("grove") treeName := c.Param("tree") grove, err := h.db.GetGrove(c.Request.Context(), groveName) if err != nil || grove == nil { c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) return } tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) if err != nil || tree == nil { c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) return } // Get the uploaded file file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) return } defer file.Close() // Store in S3 (content-addressable) hash, size, err := h.s3.Store(c.Request.Context(), file) if err != nil { h.logger.Error("failed to store artifact", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store artifact"}) return } // Create fruit record fruit := &models.Fruit{ ID: hash, Size: size, ContentType: header.Header.Get("Content-Type"), OriginalName: header.Filename, CreatedBy: c.GetString("user_id"), S3Key: fmt.Sprintf("%s/%s/%s", hash[:2], hash[2:4], hash), } if err := h.db.CreateFruit(c.Request.Context(), fruit); err != nil { h.logger.Error("failed to create fruit record", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create fruit record"}) return } // Create harvest record harvest := &models.Harvest{ FruitID: hash, TreeID: tree.ID, OriginalName: header.Filename, HarvestedBy: c.GetString("user_id"), SourceIP: c.ClientIP(), } if err := h.db.CreateHarvest(c.Request.Context(), harvest); err != nil { h.logger.Error("failed to create harvest record", zap.Error(err)) } // Optionally create a graft (tag) if specified tag := c.PostForm("tag") if tag != "" { graft := &models.Graft{ TreeID: tree.ID, Name: tag, FruitID: hash, CreatedBy: c.GetString("user_id"), } if err := h.db.CreateGraft(c.Request.Context(), graft); err != nil { h.logger.Error("failed to create graft", zap.Error(err)) } } h.logAudit(c, "cultivate", fmt.Sprintf("%s/%s/%s", groveName, treeName, hash)) c.JSON(http.StatusCreated, gin.H{ "fruit_id": hash, "size": size, "grove": groveName, "tree": treeName, "tag": tag, }) } func (h *Handler) Harvest(c *gin.Context) { groveName := c.Param("grove") treeName := c.Param("tree") ref := c.Param("ref") grove, err := h.db.GetGrove(c.Request.Context(), groveName) if err != nil || grove == nil { c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) return } tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) if err != nil || tree == nil { c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) return } // Resolve reference to fruit ID var fruitID string if strings.HasPrefix(ref, "fruit:") { // Direct fruit reference fruitID = strings.TrimPrefix(ref, "fruit:") } else if strings.HasPrefix(ref, "version:") || strings.HasPrefix(ref, "tag:") { // Tag/version reference tagName := strings.TrimPrefix(ref, "version:") tagName = strings.TrimPrefix(tagName, "tag:") graft, err := h.db.GetGraft(c.Request.Context(), tree.ID, tagName) if err != nil || graft == nil { c.JSON(http.StatusNotFound, gin.H{"error": "reference not found"}) return } fruitID = graft.FruitID } else { // Assume it's a tag name graft, err := h.db.GetGraft(c.Request.Context(), tree.ID, ref) if err != nil || graft == nil { // Maybe it's a direct fruit ID fruit, err := h.db.GetFruit(c.Request.Context(), ref) if err != nil || fruit == nil { c.JSON(http.StatusNotFound, gin.H{"error": "reference not found"}) return } fruitID = ref } else { fruitID = graft.FruitID } } // Get fruit metadata fruit, err := h.db.GetFruit(c.Request.Context(), fruitID) if err != nil || fruit == nil { c.JSON(http.StatusNotFound, gin.H{"error": "fruit not found"}) return } // Track consumer if project URL is provided projectURL := c.GetHeader("X-Orchard-Project") if projectURL != "" { h.db.TrackConsumer(c.Request.Context(), tree.ID, projectURL) } // Stream content from S3 reader, err := h.s3.Retrieve(c.Request.Context(), fruitID) if err != nil { h.logger.Error("failed to retrieve artifact", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve artifact"}) return } defer reader.Close() h.logAudit(c, "harvest", fmt.Sprintf("%s/%s/%s", groveName, treeName, fruitID)) c.Header("Content-Type", fruit.ContentType) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fruit.OriginalName)) c.Header("X-Orchard-Fruit-ID", fruitID) c.Header("X-Orchard-Size", fmt.Sprintf("%d", fruit.Size)) io.Copy(c.Writer, reader) } func (h *Handler) GetFruit(c *gin.Context) { fruitID := c.Param("fruit") fruit, err := h.db.GetFruit(c.Request.Context(), fruitID) if err != nil { h.logger.Error("failed to get fruit", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get fruit"}) return } if fruit == nil { c.JSON(http.StatusNotFound, gin.H{"error": "fruit not found"}) return } c.JSON(http.StatusOK, fruit) } // Graft handlers (aliasing/tagging) func (h *Handler) CreateGraft(c *gin.Context) { groveName := c.Param("grove") treeName := c.Param("tree") grove, err := h.db.GetGrove(c.Request.Context(), groveName) if err != nil || grove == nil { c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) return } tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) if err != nil || tree == nil { c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) return } var graft models.Graft if err := c.ShouldBindJSON(&graft); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } graft.TreeID = tree.ID graft.CreatedBy = c.GetString("user_id") // Verify fruit exists fruit, err := h.db.GetFruit(c.Request.Context(), graft.FruitID) if err != nil || fruit == nil { c.JSON(http.StatusNotFound, gin.H{"error": "fruit not found"}) return } if err := h.db.CreateGraft(c.Request.Context(), &graft); err != nil { h.logger.Error("failed to create graft", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create graft"}) return } h.logAudit(c, "create_graft", fmt.Sprintf("%s/%s/%s", groveName, treeName, graft.Name)) c.JSON(http.StatusCreated, graft) } func (h *Handler) ListGrafts(c *gin.Context) { groveName := c.Param("grove") treeName := c.Param("tree") grove, err := h.db.GetGrove(c.Request.Context(), groveName) if err != nil || grove == nil { c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) return } tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) if err != nil || tree == nil { c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) return } grafts, err := h.db.ListGrafts(c.Request.Context(), tree.ID) if err != nil { h.logger.Error("failed to list grafts", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list grafts"}) return } c.JSON(http.StatusOK, grafts) } // Consumer tracking handlers func (h *Handler) GetConsumers(c *gin.Context) { groveName := c.Param("grove") treeName := c.Param("tree") grove, err := h.db.GetGrove(c.Request.Context(), groveName) if err != nil || grove == nil { c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) return } tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) if err != nil || tree == nil { c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) return } consumers, err := h.db.GetConsumers(c.Request.Context(), tree.ID) if err != nil { h.logger.Error("failed to get consumers", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get consumers"}) return } c.JSON(http.StatusOK, consumers) } // Search handler func (h *Handler) Search(c *gin.Context) { // TODO: Implement format-aware search c.JSON(http.StatusOK, gin.H{"message": "search not yet implemented"}) } // Helper to log audit events func (h *Handler) logAudit(c *gin.Context, action, resource string) { details, _ := json.Marshal(map[string]string{ "method": c.Request.Method, "path": c.Request.URL.Path, }) log := &models.AuditLog{ Action: action, Resource: resource, UserID: c.GetString("user_id"), Details: string(details), SourceIP: c.ClientIP(), } if err := h.db.CreateAuditLog(c.Request.Context(), log); err != nil { h.logger.Error("failed to create audit log", zap.Error(err)) } }