feat(server): step import/export/parse endpoints

This commit is contained in:
2026-07-21 10:22:35 +01:00
parent 7342c46d99
commit baa7bb239d
3 changed files with 191 additions and 0 deletions
+45
View File
@@ -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 {
+86
View File
@@ -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), "", " ")
}
+60
View File
@@ -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)
}
}