Files
kandev/apps/backend/internal/workflow/handlers/handlers.go
T
2026-09-18 00:34:50 +01:00

605 lines
20 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
"github.com/kandev/kandev/internal/workflow/controller"
"github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/workflow/service"
"github.com/kandev/kandev/internal/workflow/stepevents"
ws "github.com/kandev/kandev/pkg/websocket"
)
// stepEventSource labels workflow-step events published by this surface.
const stepEventSource = "workflow-handlers"
// Handlers manages workflow HTTP and WebSocket handlers
type Handlers struct {
controller *controller.Controller
eventBus bus.EventBus
stepEvents *stepevents.Publisher
logger *logger.Logger
}
// NewHandlers creates new workflow handlers
func NewHandlers(ctrl *controller.Controller, eventBus bus.EventBus, log *logger.Logger) *Handlers {
scoped := log.WithFields(zap.String("component", "workflow-handlers"))
return &Handlers{
controller: ctrl,
eventBus: eventBus,
stepEvents: stepevents.NewPublisher(eventBus, stepEventSource, scoped),
logger: scoped,
}
}
// RegisterRoutes registers workflow HTTP and WebSocket handlers
func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.Controller, eventBus bus.EventBus, log *logger.Logger) {
handlers := NewHandlers(ctrl, eventBus, log)
handlers.registerHTTP(router)
handlers.registerWS(dispatcher)
}
func (h *Handlers) registerHTTP(router *gin.Engine) {
api := router.Group("/api/v1")
// Template routes
api.GET("/workflow/templates", h.httpListTemplates)
api.GET("/workflow/templates/:id", h.httpGetTemplate)
// Step routes
api.GET("/workflows/:id/workflow/steps", h.httpListStepsByWorkflow)
api.GET("/workspaces/:id/workflow-steps", h.httpListStepsByWorkspace)
api.GET("/workflow/steps/:id", h.httpGetStep)
api.POST("/workflows/:id/workflow/steps", h.httpCreateStepsFromTemplate)
api.POST("/workflow/steps", h.httpCreateStep)
api.PUT("/workflow/steps/:id", h.httpUpdateStep)
api.DELETE("/workflow/steps/:id", h.httpDeleteStep)
api.PUT("/workflows/:id/workflow/steps/reorder", h.httpReorderSteps)
// Export/Import routes
api.GET("/workflows/:id/export", h.httpExportWorkflow)
api.GET("/workspaces/:id/workflows/export", h.httpExportWorkflows)
api.POST("/workspaces/:id/workflows/import/preview", h.httpPreviewImportWorkflows)
api.POST("/workspaces/:id/workflows/import", h.httpImportWorkflows)
// History routes
api.GET("/sessions/:id/workflow/history", h.httpListHistoryBySession)
}
func (h *Handlers) registerWS(dispatcher *ws.Dispatcher) {
dispatcher.RegisterFunc(ws.ActionWorkflowTemplateList, h.wsListTemplates)
dispatcher.RegisterFunc(ws.ActionWorkflowTemplateGet, h.wsGetTemplate)
dispatcher.RegisterFunc(ws.ActionWorkflowStepList, h.wsListSteps)
dispatcher.RegisterFunc(ws.ActionWorkflowStepGet, h.wsGetStep)
dispatcher.RegisterFunc(ws.ActionWorkflowStepCreate, h.wsCreateStepsFromTemplate)
dispatcher.RegisterFunc(ws.ActionWorkflowHistoryList, h.wsListHistory)
}
// HTTP handlers - Templates
func (h *Handlers) httpListTemplates(c *gin.Context) {
resp, err := h.controller.ListTemplates(c.Request.Context())
if err != nil {
h.logger.Error("failed to list templates", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list templates"})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpGetTemplate(c *gin.Context) {
resp, err := h.controller.GetTemplate(c.Request.Context(), c.Param("id"))
if err != nil {
h.logger.Error("failed to get template", zap.Error(err))
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
c.JSON(http.StatusOK, resp)
}
// HTTP handlers - Steps
func (h *Handlers) httpListStepsByWorkflow(c *gin.Context) {
resp, err := h.controller.ListStepsByWorkflow(c.Request.Context(), controller.ListStepsRequest{
WorkflowID: c.Param("id"),
})
if err != nil {
h.logger.Error("failed to list steps", zap.Error(err))
if writeNotVisible(c, err, "Workflow not found") {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list steps"})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpListStepsByWorkspace(c *gin.Context) {
resp, err := h.controller.ListStepsByWorkspace(c.Request.Context(), c.Param("id"))
if err != nil {
h.logger.Error("failed to list steps by workspace", zap.Error(err))
if writeNotVisible(c, err, "Workspace not found") {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list steps"})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpGetStep(c *gin.Context) {
resp, err := h.controller.GetStep(c.Request.Context(), c.Param("id"))
if err != nil {
h.logger.Error("failed to get step", zap.Error(err))
c.JSON(http.StatusNotFound, gin.H{"error": "Step not found"})
return
}
c.JSON(http.StatusOK, resp)
}
type httpCreateStepsRequest struct {
TemplateID string `json:"template_id"`
}
func (h *Handlers) httpCreateStepsFromTemplate(c *gin.Context) {
var req httpCreateStepsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if err := h.controller.CreateStepsFromTemplate(c.Request.Context(), controller.CreateStepsFromTemplateRequest{
WorkflowID: c.Param("id"),
TemplateID: req.TemplateID,
}); err != nil {
h.logger.Error("failed to create steps from template", zap.Error(err))
h.writeStepMutationError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"success": true})
}
func (h *Handlers) httpCreateStep(c *gin.Context) {
var req controller.CreateStepRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
if req.WorkflowID == "" || req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_id and name are required"})
return
}
ctx := c.Request.Context()
resp, err := h.controller.CreateStep(ctx, req)
if err != nil {
h.logger.Error("failed to create step", zap.Error(err))
h.writeStepMutationError(c, err)
return
}
h.publishWorkflowStepEvents(ctx, events.WorkflowStepUpdated, resp.DemotedStartSteps)
h.publishWorkflowStepEvent(ctx, events.WorkflowStepCreated, resp.Step)
c.JSON(http.StatusCreated, resp.Step)
}
func (h *Handlers) httpUpdateStep(c *gin.Context) {
var req controller.UpdateStepRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
req.ID = c.Param("id")
ctx := c.Request.Context()
resp, err := h.controller.UpdateStep(ctx, req)
if err != nil {
h.logger.Error("failed to update step", zap.Error(err))
h.writeStepMutationError(c, err)
return
}
h.publishWorkflowStepEvents(ctx, events.WorkflowStepUpdated, resp.DemotedStartSteps)
h.publishWorkflowStepEvent(ctx, events.WorkflowStepUpdated, resp.Step)
c.JSON(http.StatusOK, resp.Step)
}
// writeNotVisible answers 404 for a workflow, workspace or step the caller may
// not see, and reports whether it handled the error. The same 404 covers a
// resource that does not exist, so neither response reveals which it was.
func writeNotVisible(c *gin.Context, err error, message string) bool {
if !errors.Is(err, service.ErrNotVisible) {
return false
}
c.JSON(http.StatusNotFound, gin.H{"error": message})
return true
}
func (h *Handlers) writeStepMutationError(c *gin.Context, err error) {
if errors.Is(err, service.ErrWorkflowReadOnly) || errors.Is(err, service.ErrWorkflowWorkspaceReadOnly) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
// A step the caller may not see reads exactly like one that is not there.
if writeNotVisible(c, err, "Step not found") {
return
}
msg := strings.ToLower(err.Error())
switch {
case isStepValidationError(msg):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case strings.Contains(msg, "not found"):
c.JSON(http.StatusNotFound, gin.H{"error": "Step not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save step"})
}
}
func isStepValidationError(msg string) bool {
return strings.Contains(msg, "wip_limit must be non-negative") ||
strings.Contains(msg, "pull_from_step_id") ||
strings.Contains(msg, "same workflow") ||
strings.Contains(msg, "pull cycle")
}
func (h *Handlers) httpDeleteStep(c *gin.Context) {
ctx := c.Request.Context()
stepID := c.Param("id")
stepResp, getErr := h.controller.GetStep(ctx, stepID)
// A step the caller may not see is the ordinary rejection below, not a
// failure to read one — warning about it would file every unauthorized
// delete under infrastructure trouble.
if getErr != nil && !errors.Is(getErr, service.ErrNotVisible) {
h.logger.Warn("failed to fetch step before delete; workflow_step.deleted event will not be published",
zap.String("step_id", stepID), zap.Error(getErr))
}
if err := h.controller.DeleteStep(ctx, stepID); err != nil {
h.logger.Error("failed to delete step", zap.Error(err))
h.writeStepMutationError(c, err)
return
}
if stepResp != nil {
h.publishWorkflowStepEvent(ctx, events.WorkflowStepDeleted, stepResp.Step)
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func (h *Handlers) publishWorkflowStepEvent(ctx context.Context, eventType string, step *models.WorkflowStep) {
h.stepEvents.Publish(ctx, eventType, step)
}
func (h *Handlers) publishWorkflowStepEvents(ctx context.Context, eventType string, steps []*models.WorkflowStep) {
h.stepEvents.PublishAll(ctx, eventType, steps)
}
type httpReorderStepsRequest struct {
StepIDs []string `json:"step_ids"`
}
func (h *Handlers) httpReorderSteps(c *gin.Context) {
var req httpReorderStepsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
if err := h.controller.ReorderSteps(c.Request.Context(), controller.ReorderStepsRequest{
WorkflowID: c.Param("id"),
StepIDs: req.StepIDs,
}); err != nil {
h.logger.Error("failed to reorder steps", zap.Error(err))
h.writeStepMutationError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// HTTP handlers - History
func (h *Handlers) httpListHistoryBySession(c *gin.Context) {
resp, err := h.controller.ListHistoryBySession(c.Request.Context(), controller.ListHistoryRequest{
SessionID: c.Param("id"),
})
if err != nil {
h.logger.Error("failed to list history", zap.Error(err))
// Session IDs are opaque, so denied/missing access is reported as
// not-found rather than leaking whether another workspace owns the
// session. Any other error (e.g. a repository read failure) is a
// genuine server error and must not be masked as not-found.
if errors.Is(err, taskmodels.ErrTaskSessionNotFound) || errors.Is(err, repoerrors.ErrTaskNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list history"})
return
}
c.JSON(http.StatusOK, resp)
}
// HTTP handlers - Export/Import
func (h *Handlers) httpExportWorkflow(c *gin.Context) {
resp, err := h.controller.ExportWorkflow(c.Request.Context(), c.Param("id"))
if err != nil {
h.logger.Error("failed to export workflow", zap.Error(err))
if writeNotVisible(c, err, "Workflow not found") {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to export workflow"})
return
}
h.respondYAML(c, resp)
}
func (h *Handlers) httpExportWorkflows(c *gin.Context) {
resp, err := h.controller.ExportWorkflows(c.Request.Context(), c.Param("id"), parseExportIDs(c))
if err != nil {
h.logger.Error("failed to export workflows", zap.Error(err))
if writeNotVisible(c, err, "Workspace not found") {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to export workflows"})
return
}
h.respondYAML(c, resp)
}
// parseExportIDs reads the optional comma-separated `ids` query param. It
// returns nil when the param is absent (export all, back-compat) and a non-nil
// slice (possibly empty) when present, restricting the export to those IDs.
func parseExportIDs(c *gin.Context) []string {
raw, ok := c.GetQuery("ids")
if !ok {
return nil
}
ids := []string{}
for _, part := range strings.Split(raw, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
ids = append(ids, trimmed)
}
}
return ids
}
func (h *Handlers) httpImportWorkflows(c *gin.Context) {
body, tooLarge, err := readImportBody(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
if tooLarge {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Import request exceeds the 1 MiB limit"})
return
}
request, err := parseImportWorkflowsRequest(c, body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": capitalizeImportError(err.Error())})
return
}
resp, err := h.controller.ImportWorkflows(c.Request.Context(), request)
if err != nil {
h.writeImportError(c, err)
return
}
c.JSON(http.StatusOK, resp)
}
func parseImportWorkflowsRequest(c *gin.Context, body []byte) (controller.ImportWorkflowsRequest, error) {
request := controller.ImportWorkflowsRequest{WorkspaceID: c.Param("id")}
if importMediaType(c) != "application/json" {
var data models.WorkflowExport
if err := yaml.Unmarshal(body, &data); err != nil {
return request, fmt.Errorf("invalid YAML: %w", err)
}
request.Data = &data
return request, nil
}
var envelope struct {
YAML string `json:"yaml"`
StepProfileBindings []service.ImportProfileBinding `json:"step_profile_bindings"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return request, fmt.Errorf("invalid JSON: %w", err)
}
if strings.TrimSpace(envelope.YAML) == "" {
return request, errors.New("the import YAML is required")
}
var data models.WorkflowExport
if err := yaml.Unmarshal([]byte(envelope.YAML), &data); err != nil {
return request, fmt.Errorf("invalid YAML: %w", err)
}
if envelope.StepProfileBindings == nil {
envelope.StepProfileBindings = []service.ImportProfileBinding{}
}
request.Data = &data
request.StepProfileBindings = envelope.StepProfileBindings
return request, nil
}
func capitalizeImportError(message string) string {
if message == "" {
return message
}
return strings.ToUpper(message[:1]) + message[1:]
}
func (h *Handlers) httpPreviewImportWorkflows(c *gin.Context) {
body, tooLarge, err := readImportBody(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
if tooLarge {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Import request exceeds the 1 MiB limit"})
return
}
var data models.WorkflowExport
if err := yaml.Unmarshal(body, &data); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid YAML: " + err.Error()})
return
}
resp, err := h.controller.PreviewImportWorkflows(c.Request.Context(), c.Param("id"), &data)
if err != nil {
h.writeImportError(c, err)
return
}
c.JSON(http.StatusOK, resp)
}
const maxImportSize = 1 << 20
func readImportBody(c *gin.Context) ([]byte, bool, error) {
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxImportSize+1))
if err != nil {
return nil, false, err
}
return body, len(body) > maxImportSize, nil
}
func importMediaType(c *gin.Context) string {
mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type"))
if err != nil {
return strings.ToLower(strings.TrimSpace(strings.Split(c.GetHeader("Content-Type"), ";")[0]))
}
return strings.ToLower(mediaType)
}
func (h *Handlers) writeImportError(c *gin.Context, err error) {
if writeNotVisible(c, err, "Workspace not found") {
return
}
var resolutionErr *service.ImportProfileResolutionError
if errors.As(err, &resolutionErr) {
c.JSON(http.StatusConflict, gin.H{
"code": "workflow_import_profiles_required",
"error": resolutionErr.Error(),
"steps": resolutionErr.Conflicts,
})
return
}
var bindingsErr *service.InvalidImportProfileBindingsError
if errors.As(err, &bindingsErr) {
c.JSON(http.StatusBadRequest, gin.H{"code": "invalid_workflow_import_profile_bindings", "error": bindingsErr.Error()})
return
}
if errors.Is(err, service.ErrImportProfileCatalogUnavailable) {
h.logger.Error("failed to import workflows", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Import profile catalog is unavailable"})
return
}
h.logger.Error("failed to import workflows", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
// respondYAML marshals the value as YAML and writes it to the response.
func (h *Handlers) respondYAML(c *gin.Context, v any) {
data, err := yaml.Marshal(v)
if err != nil {
h.logger.Error("failed to marshal YAML", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal response"})
return
}
c.Data(http.StatusOK, "application/x-yaml", data)
}
// WS handlers - Templates
func (h *Handlers) wsListTemplates(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
resp, err := h.controller.ListTemplates(ctx)
if err != nil {
h.logger.Error("failed to list templates", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list templates", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
func (h *Handlers) wsGetTemplate(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return h.wsGetByID(ctx, msg, "failed to get template", "Template not found",
func(ctx context.Context, id string) (any, error) {
return h.controller.GetTemplate(ctx, id)
})
}
// WS handlers - Steps
type wsListStepsRequest struct {
WorkflowID string `json:"workflow_id"`
}
func (h *Handlers) wsListSteps(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsListStepsRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
return h.wsHandleStringField(ctx, msg, req.WorkflowID, "workflow_id",
"failed to list steps", "Failed to list steps",
func(ctx context.Context, workflowID string) (any, error) {
return h.controller.ListStepsByWorkflow(ctx, controller.ListStepsRequest{WorkflowID: workflowID})
})
}
func (h *Handlers) wsGetStep(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return h.wsGetByID(ctx, msg, "failed to get step", "Step not found",
func(ctx context.Context, id string) (any, error) {
return h.controller.GetStep(ctx, id)
})
}
type wsCreateStepsRequest struct {
WorkflowID string `json:"workflow_id"`
TemplateID string `json:"template_id"`
}
func (h *Handlers) wsCreateStepsFromTemplate(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsCreateStepsRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.WorkflowID == "" || req.TemplateID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id and template_id are required", nil)
}
if err := h.controller.CreateStepsFromTemplate(ctx, controller.CreateStepsFromTemplateRequest{
WorkflowID: req.WorkflowID,
TemplateID: req.TemplateID,
}); err != nil {
h.logger.Error("failed to create steps", zap.Error(err))
if errors.Is(err, service.ErrNotVisible) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, notFoundMessage, nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create steps", nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true})
}
// WS handlers - History
type wsListHistoryRequest struct {
SessionID string `json:"session_id"`
}
func (h *Handlers) wsListHistory(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsListHistoryRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
return h.wsHandleStringField(ctx, msg, req.SessionID, "session_id",
"failed to list history", "Failed to list history",
func(ctx context.Context, sessionID string) (any, error) {
return h.controller.ListHistoryBySession(ctx, controller.ListHistoryRequest{SessionID: sessionID})
})
}