From baa7bb239d243048c5bd28370772e170831e0783 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 10:22:35 +0100 Subject: [PATCH] feat(server): step import/export/parse endpoints --- server/internal/api/workflows.go | 45 +++++++++++++ server/internal/services/stepio.go | 86 +++++++++++++++++++++++++ server/internal/services/stepio_test.go | 60 +++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 server/internal/services/stepio.go create mode 100644 server/internal/services/stepio_test.go diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index de8780c..1e061e0 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "io" "net/http" "os" "regexp" @@ -19,6 +20,9 @@ func registerWorkflowRoutes(g *gin.RouterGroup) { g.POST("/steps", createStep) g.PUT("/steps/:id", updateStep) g.DELETE("/steps/:id", deleteStep) + g.GET("/steps/:id/export", exportStep) + g.POST("/steps/import", importStep) + g.POST("/steps/parse", parseStep) g.GET("/workflows", listWorkflows) g.POST("/workflows", createWorkflow) @@ -187,6 +191,47 @@ func deleteStep(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"deleted": true}) } +func exportStep(c *gin.Context) { + b, err := services.ExportStep(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=step-%s.json", c.Param("id"))) + c.Data(http.StatusOK, "application/json", b) +} + +func importStep(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + out, err := services.ImportStepToLibrary(body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + services.LogEvent("workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name)) + c.JSON(http.StatusCreated, out) +} + +// parseStep validates a step doc and returns the normalized step WITHOUT +// persisting — used by the editor to insert an imported ad-hoc (inline) step. +func parseStep(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s, err := services.ParseStepDoc(body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, s) +} + func listWorkflows(c *gin.Context) { wfs, err := services.ListWorkflows() if err != nil { diff --git a/server/internal/services/stepio.go b/server/internal/services/stepio.go new file mode 100644 index 0000000..ad6f12e --- /dev/null +++ b/server/internal/services/stepio.go @@ -0,0 +1,86 @@ +package services + +import ( + "encoding/json" + "fmt" + + "github.com/mrhid6/vantage/server/internal/models" +) + +const StepDocKind = "vantage.step/v1" + +// StepDoc is the portable, id-free representation of a step. +type StepDoc struct { + Kind string `json:"kind"` + Name string `json:"name"` + Description string `json:"description"` + Interpreter string `json:"interpreter"` + Script string `json:"script"` + DeclaredOutputs []string `json:"declared_outputs"` + DeclaredInputs []models.InputParam `json:"declared_inputs"` + SecretRefs []string `json:"secret_refs"` +} + +// ExportStepDoc builds a portable doc from a library step (ids/source stripped). +func ExportStepDoc(s models.WorkflowStep) StepDoc { + return StepDoc{ + Kind: StepDocKind, + Name: s.Name, + Description: s.Description, + Interpreter: s.Interpreter, + Script: s.Script, + DeclaredOutputs: s.DeclaredOutputs, + DeclaredInputs: s.DeclaredInputs, + SecretRefs: s.SecretRefs, + } +} + +// ParseStepDoc validates a v1 doc and returns a normalized (id-free) step with +// declared_outputs recomputed from the script. +func ParseStepDoc(b []byte) (models.WorkflowStep, error) { + var d StepDoc + if err := json.Unmarshal(b, &d); err != nil { + return models.WorkflowStep{}, fmt.Errorf("invalid step JSON: %w", err) + } + if d.Kind != StepDocKind { + return models.WorkflowStep{}, fmt.Errorf("unsupported kind %q (want %q)", d.Kind, StepDocKind) + } + if d.Name == "" || d.Interpreter == "" { + return models.WorkflowStep{}, fmt.Errorf("step name and interpreter are required") + } + if d.SecretRefs == nil { + d.SecretRefs = []string{} + } + if d.DeclaredInputs == nil { + d.DeclaredInputs = []models.InputParam{} + } + return models.WorkflowStep{ + Name: d.Name, + Description: d.Description, + Interpreter: d.Interpreter, + Script: d.Script, + DeclaredOutputs: DeriveOutputs(d.Script), + DeclaredInputs: d.DeclaredInputs, + SecretRefs: d.SecretRefs, + }, nil +} + +// ImportStepToLibrary parses a doc and persists it as a new user library step. +func ImportStepToLibrary(b []byte) (*models.WorkflowStep, error) { + s, err := ParseStepDoc(b) + if err != nil { + return nil, err + } + return CreateStep(s) +} + +// ExportStep loads a library step and marshals it to a portable doc. +func ExportStep(stepID string) ([]byte, error) { + ctx, cancel := wfCtx() + defer cancel() + s, err := getStep(ctx, stepID) + if err != nil { + return nil, err + } + return json.MarshalIndent(ExportStepDoc(*s), "", " ") +} diff --git a/server/internal/services/stepio_test.go b/server/internal/services/stepio_test.go new file mode 100644 index 0000000..d5499e7 --- /dev/null +++ b/server/internal/services/stepio_test.go @@ -0,0 +1,60 @@ +package services + +import ( + "encoding/json" + "testing" + + "github.com/mrhid6/vantage/server/internal/models" +) + +func mkStep() models.WorkflowStep { + return models.WorkflowStep{ + StepID: "should-not-export", Source: "default", Name: "Restart", + Interpreter: "bash", Script: "echo x=1 >> $WORKFLOW_ENV", + SecretRefs: []string{"TOK"}, + } +} + +func TestParseStepDocValid(t *testing.T) { + raw := `{"kind":"vantage.step/v1","name":"Restart","interpreter":"bash", + "script":"echo x=1 >> $WORKFLOW_ENV","declared_outputs":["stale"], + "declared_inputs":[{"name":"A","default":"1"}],"secret_refs":["TOK"]}` + s, err := ParseStepDoc([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if s.Name != "Restart" || s.Interpreter != "bash" { + t.Fatalf("bad parse: %+v", s) + } + // declared_outputs recomputed from script, ignoring the file's ["stale"]. + if len(s.DeclaredOutputs) != 1 || s.DeclaredOutputs[0] != "x" { + t.Fatalf("outputs should be derived, got %v", s.DeclaredOutputs) + } + if s.StepID != "" || s.Source != "" { + t.Fatalf("parse must not set id/source") + } +} + +func TestParseStepDocBadKind(t *testing.T) { + if _, err := ParseStepDoc([]byte(`{"kind":"nope","name":"x"}`)); err == nil { + t.Fatal("want error for bad kind") + } +} + +func TestParseStepDocBadJSON(t *testing.T) { + if _, err := ParseStepDoc([]byte(`{`)); err == nil { + t.Fatal("want error for bad json") + } +} + +func TestExportStepDocRoundTrip(t *testing.T) { + doc := ExportStepDoc(mkStep()) + b, _ := json.Marshal(doc) + s, err := ParseStepDoc(b) + if err != nil { + t.Fatal(err) + } + if s.Name != "Restart" || s.Interpreter != "bash" { + t.Fatalf("round trip lost data: %+v", s) + } +}