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
This commit is contained in:
442
internal/api/handlers.go
Normal file
442
internal/api/handlers.go
Normal file
@@ -0,0 +1,442 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user