Files
orchard/internal/api/handlers.go
Mondo Diaz f8e9650de3 Initial commit: Orchard content-addressable storage system
- Go server with Gin framework
- PostgreSQL for metadata storage
- MinIO/S3 for artifact storage with SHA256 content addressing
- REST API for grove/tree/fruit operations
- Web UI for managing artifacts
- Docker Compose setup for local development
2025-12-04 10:14:49 -06:00

443 lines
12 KiB
Go

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))
}
}