From 4a27841bc720285e057283f584b9217e206832ee Mon Sep 17 00:00:00 2001 From: samzong Date: Fri, 3 Jul 2026 03:21:58 -0400 Subject: [PATCH] feat(workflow): add workflow DSL commands Signed-off-by: samzong --- docs/workflow.md | 231 ++++++++------ internal/codegen/app/app.go | 19 +- internal/codegen/render/render.go | 333 +++++++++++++++++++- internal/codegen/render/render_test.go | 63 ++++ internal/lathecmd/lathecmd.go | 5 + internal/lathecmd/lathecmd_test.go | 126 ++++++++ internal/lathecmd/workflow.go | 307 ++++++++++++++++++ pkg/config/manifest.go | 148 ++++++++- pkg/config/manifest_test.go | 56 +++- pkg/lathe/verify.go | 34 ++ pkg/lathe/verify_test.go | 35 +++ pkg/runtime/build.go | 412 +++++++------------------ pkg/runtime/catalog.go | 100 +++++- pkg/runtime/catalog_test.go | 63 ++++ pkg/runtime/operation.go | 412 +++++++++++++++++++++++++ pkg/runtime/spec.go | 28 ++ pkg/runtime/workflow.go | 317 +++++++++++++++++++ pkg/runtime/workflow_test.go | 222 +++++++++++++ 18 files changed, 2494 insertions(+), 417 deletions(-) create mode 100644 internal/lathecmd/workflow.go create mode 100644 pkg/runtime/operation.go create mode 100644 pkg/runtime/workflow.go create mode 100644 pkg/runtime/workflow_test.go diff --git a/docs/workflow.md b/docs/workflow.md index 2d831c0..48956cb 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -1,16 +1,16 @@ # Workflow Commands -Status: proposed. +Status: implemented v0. ## Purpose -Workflow commands let a CLI builder publish a normal command whose implementation -is a deterministic sequence of generated API operations. +Workflow commands let a CLI builder publish a normal command whose +implementation is a deterministic sequence of generated API operations. The user-facing command should look like any other Cobra command: ```sh -mycli doctor --json +mycli doctor -o json mycli deploy --app-id app_123 ``` @@ -38,114 +38,157 @@ operations. It can remain custom Go until diagnostic primitives are designed. The first version does not support: - Rollback or compensation. -- `if` / `else`, loops, parallel steps, or retry policy. +- `if` / `else`, loops, parallel steps, or workflow-specific retry policy. - Shell commands or external actions. - Dynamic plugins or remote extension code. - A public `GeneratedApp` or workflow engine ABI. -- End-user commands not explicitly named by the CLI builder. +- Local diagnostic primitives. +- Workflow-level dry-run. ## Failure Semantics Workflow commands run steps in order and stop at the first failure. -If step N fails, steps 1 through N-1 may already have changed remote state. Lathe -does not roll them back. API specifications do not provide a universal undo -contract, so automatic rollback would be a false transaction. +If step N fails, steps 1 through N-1 may already have changed remote state. +Lathe does not roll them back. API specifications do not provide a universal +undo contract, so automatic rollback would be a false transaction. -Failures must be structured. JSON output should include: +The current runtime returns a `WorkflowError` with the failed step ID and the +completed step summary. If `output.from` is omitted, successful workflows output +a small JSON step summary: -- The failed step ID. -- The underlying error class. -- Completed step IDs. -- The successful step outputs needed for manual recovery. - -Human output should explain that partial completion may have occurred. +```json +{"status":"ok","steps":[{"id":"health","status":"ok"}]} +``` ## Configuration -Workflow configuration belongs in `cli.yaml` as its own domain block: +Workflow configuration lives in `cli.yaml` under `workflow`: ```yaml workflow: - root: workflows + version: 1 commands: - - doctor.yaml + - use: doctor + short: Check CLI API readiness + inputs: + - name: app_id + flag: app-id + type: string + required: true + help: App ID to inspect + steps: + - id: app + uses: console.Apps_Get + params: + appId: ${input.app_id} + - id: deployment + uses: console.Apps_DeploymentStatus + params: + appId: ${input.app_id} + output: + from: ${steps.deployment} ``` -Paths are relative to the directory containing `cli.yaml`. The root defaults to -`workflows` when omitted. +`commands[].use` is mounted as a root command. The example above produces +`mycli doctor`. -Do not add an `extensions:` block for first-party workflow commands. +## Operation References -## DSL Shape +Each step uses one generated API operation. The recommended reference form is: -Each workflow file defines one user-facing command: +```yaml +uses: . +``` + +For example, a source named `console` with `operationId: Apps_Get` is referenced +as `console.Apps_Get`. + +Lathe also accepts generated command-path references for specs with awkward +operation IDs: ```yaml -version: 1 -command: - use: doctor - short: Check CLI API readiness - long: Check the configured API by running read-only generated operations. - aliases: [check] +uses: console apps get +uses: console.apps.get +``` + +Ambiguous references fail at codegen time. +## Inputs And References + +Workflow inputs become normal command flags. + +```yaml inputs: - app_id: - flag: app-id + - name: tenant_id + flag: tenant type: string required: true - help: App ID to inspect +``` + +Supported input types are `string`, `int64`, `float64`, `bool`, and the matching +slice forms `[]string`, `[]int64`, `[]float64`, `[]bool`. + +References use `${...}`: + +- `${input.tenant_id}` reads a workflow input. +- `${steps.health}` reads the full JSON output of a prior step. +- `${steps.health.data.id}` reads a dotted path from a prior JSON output. + +Step IDs must not contain dots. References to unknown inputs or later steps fail +at codegen time. +## Step Parameters And Bodies + +`params` maps workflow values into the target operation's parameters by +operation parameter name or flag name. Codegen validates every key. + +```yaml steps: - id: app - operation: "console apps app-detail" + uses: console.Apps_Get params: - appId: "${input.app_id}" - capture: - status: "app.status" - deployment: "app.deployment.status" + appId: ${input.app_id} +``` - - id: deployment - operation: "console apps app-deployment-status" - params: - appId: "${input.app_id}" - capture: - state: "deployment.status" +JSON request bodies can be built with `set` and `set_str`, matching generated +API command body flags: -output: - app_status: "${steps.app.status}" - deployment_status: "${steps.deployment.state}" +```yaml +steps: + - id: create + uses: console.Apps_Create + set: + input.name: ${input.name} + input.replicas: "3" + set_str: + input.label: ${input.label} ``` -`operation` is the generated command path shown by `commands show`. Codegen must -validate that the path exists and maps to exactly one generated operation. +## Output -`params` keys match generated parameter names or flags. Codegen must validate -that every key maps to a known `runtime.ParamSpec`. +If `output.from` is set, the command outputs that referenced value using the +normal `-o` formatter. -`capture` and `output` use dot paths over JSON responses. Missing paths are -errors unless the field is marked optional in a later schema version. +If `output.from` is omitted, the command outputs the workflow step summary. ## Runtime Model -Workflow commands must call the same operation path as generated Cobra API -commands. They must not shell out to the CLI. +Workflow commands call the same operation path as generated Cobra API commands. +They do not shell out to the CLI. -First extract a runtime operation invoker: +Generated API commands are thin Cobra adapters around: ```go InvokeOperation(ctx, spec, input, opts) (result, error) ``` -Generated API commands become thin Cobra adapters around that function. Workflow -commands call it directly with compiled `runtime.CommandSpec` values. - The invoker owns: - Parameter validation and enum checks. - Request path, query, header, form, and body construction. - Auth and host behavior equivalent to generated API commands. -- Dry-run request resolution. +- Dry-run request resolution for generated API commands. - Pagination and wait behavior. - Output bytes and HTTP errors. @@ -153,21 +196,21 @@ The generated workflow command owns: - Step ordering. - Input interpolation. -- Capturing JSON fields from step outputs. -- Structured partial-failure reporting. +- Reading JSON fields from prior step outputs. +- Fail-fast behavior. ## Generated Output -Codegen should emit workflow commands as static generated Go: +Codegen emits workflow commands as static generated Go: ```text internal/generated/workflows/workflows_gen.go ``` -The generated package should mount workflow commands through `generated.Mount` -after generated API modules and before returning. +The generated package mounts workflow commands through `generated.Mount` after +generated API modules and before bundled Skill commands. -Workflow command names are reserved root command names. Codegen must reject +Workflow command names are reserved root command names. Codegen rejects conflicts with: - Lathe framework commands: `auth`, `commands`, `search`, `update`, `skill`, @@ -178,58 +221,50 @@ conflicts with: ## Catalog Contract -Workflow commands should be discoverable through the runtime catalog, but they -must not pretend to be single API operations. +Workflow commands are discoverable through the runtime catalog, but they do not +pretend to be single API operations. -Add a command kind: +Operation commands use: + +```json +{"kind":"operation"} +``` + +Workflow commands use: ```json { "kind": "workflow", "path": ["doctor"], "workflow": { - "version": 1, + "dsl": "lathe.workflow.v1", + "output_from": "${steps.deployment}", "steps": [ - {"id": "app", "operation": ["console", "apps", "app-detail"]}, - {"id": "deployment", "operation": ["console", "apps", "app-deployment-status"]} - ], - "rollback": false, - "branching": false + { + "id": "app", + "operation_id": "Apps_Get", + "http": {"method": "GET", "path_template": "/apps/{appId}"} + } + ] } } ``` -This requires a catalog schema bump. Operation commands can use -`"kind": "operation"` or omit the field only if the schema defines that as the -default. - -The binary should also attach a capability: +The catalog schema version is `11` for this contract. Generated binaries with +workflow commands also attach capability: ```text workflow.dsl ``` -## Dry Run - -Workflow commands should expose `--dry-run`. - -Dry-run must not send HTTP requests. It should resolve and print the ordered -plan, including each step's operation path and resolved request shape when all -inputs are available. - -For JSON output, dry-run should use a stable shape so CI and agents can validate -workflow wiring without touching a real API. - ## Verification -`__lathe verify --json` should add a workflow contract check when workflows are +`__lathe verify --json` adds a `workflow_contract` check when workflows are compiled in: -- Every workflow command is mounted. +- At least one workflow command is present when `workflow.dsl` is attached. - Every workflow command appears in catalog JSON as `kind=workflow`. -- Every referenced operation resolves. -- Every workflow command dry-run completes without network access. -- Root command conflicts were rejected at codegen time. +- Every workflow command has workflow metadata. +- Every workflow step has an ID and operation HTTP metadata. -The verify report schema does not need to change unless the check result shape -changes. +The verify report schema does not change. diff --git a/internal/codegen/app/app.go b/internal/codegen/app/app.go index 074dc99..ba78b52 100644 --- a/internal/codegen/app/app.go +++ b/internal/codegen/app/app.go @@ -12,9 +12,10 @@ import ( // App is the complete set of generated outputs for one codegen run. type App struct { - Manifest *config.Manifest - Modules []Module - Skill *Skill + Manifest *config.Manifest + Modules []Module + Workflows []runtime.WorkflowSpec + Skill *Skill } // Module is one generated command module and how it mounts on the root command. @@ -44,6 +45,10 @@ func (a *App) Validate() error { } names = append(names, m.CLIName) } + for _, workflow := range a.Workflows { + names = append(names, workflow.Use) + names = append(names, workflow.Aliases...) + } return render.ValidateModuleNames(names) } @@ -60,6 +65,14 @@ func (a *App) Write() error { if a.Skill != nil && a.Skill.Bundle { opts.SkillBundle = &render.SkillBundleMount{Root: render.SkillDirName(a.Manifest.CLI.Name)} } + if len(a.Workflows) > 0 { + opts.Workflows = true + if err := render.RenderWorkflows(a.Workflows); err != nil { + return err + } + } else if err := render.RemoveWorkflowsPackage(); err != nil { + return err + } if err := render.RenderModulesGenWithOptions(mounts, opts); err != nil { return err } diff --git a/internal/codegen/render/render.go b/internal/codegen/render/render.go index 3118487..262621d 100644 --- a/internal/codegen/render/render.go +++ b/internal/codegen/render/render.go @@ -20,6 +20,7 @@ const ( GeneratedRoot = "internal/generated" ModulesGenFile = "internal/generated/modules_gen.go" SkillBundleDir = "internal/generated/skillbundle" + WorkflowsDir = "internal/generated/workflows" ) type moduleCtx struct { @@ -37,12 +38,18 @@ type ModuleMount struct { type ModulesGenOptions struct { SkillBundle *SkillBundleMount + Workflows bool } type SkillBundleMount struct { Root string } +type workflowCtx struct { + RuntimePkg string + Specs []runtime.WorkflowSpec +} + // RuntimePkg is the import path downstream-generated modules use to reach // lathe's runtime. Downstream forks import lathe as a library; they do not // vendor or copy the runtime package into their own tree. @@ -471,7 +478,8 @@ func RenderModulesGenWithOptions(modules []ModuleMount, opts ModulesGenOptions) Prefix string Modules []ModuleMount SkillBundle *SkillBundleMount - }{Prefix: mp + "/internal/generated/", Modules: modules, SkillBundle: opts.SkillBundle}); err != nil { + Workflows bool + }{Prefix: mp + "/internal/generated/", Modules: modules, SkillBundle: opts.SkillBundle, Workflows: opts.Workflows}); err != nil { return err } formatted, err := format.Source([]byte(buf.String())) @@ -486,6 +494,31 @@ func RenderModulesGenWithOptions(modules []ModuleMount, opts ModulesGenOptions) return nil } +func RenderWorkflows(specs []runtime.WorkflowSpec) error { + var buf strings.Builder + if err := workflowsTmpl.Execute(&buf, workflowCtx{RuntimePkg: RuntimePkg, Specs: specs}); err != nil { + return err + } + outPath := filepath.Join(WorkflowsDir, "workflows_gen.go") + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return err + } + formatted, err := format.Source([]byte(buf.String())) + if err != nil { + _ = os.WriteFile(outPath+".unformatted", []byte(buf.String()), 0o644) + return err + } + if err := os.WriteFile(outPath, formatted, 0o644); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "wrote %s: %d workflows\n", outPath, len(specs)) + return nil +} + +func RemoveWorkflowsPackage() error { + return os.RemoveAll(WorkflowsDir) +} + func RenderSkillBundlePackage(skillDir string, cliName string) error { root := SkillDirName(cliName) dst := filepath.Join(SkillBundleDir, root) @@ -582,6 +615,273 @@ func stringMapLiteral(values map[string]string) string { return b.String() } +func workflowSpecLiteral(spec runtime.WorkflowSpec) string { + var b strings.Builder + b.WriteString("runtime.WorkflowSpec{") + writeStringField(&b, "Use", spec.Use) + writeStringSliceField(&b, "Aliases", spec.Aliases) + writeStringField(&b, "Short", spec.Short) + writeStringField(&b, "Long", spec.Long) + writeStringField(&b, "Example", spec.Example) + writeBoolField(&b, "Hidden", spec.Hidden) + writeBoolField(&b, "Deprecated", spec.Deprecated) + if len(spec.Params) > 0 { + fmt.Fprintf(&b, "Params: %s,", paramSpecsLiteral(spec.Params)) + } + if len(spec.Steps) > 0 { + fmt.Fprintf(&b, "Steps: %s,", workflowStepSpecsLiteral(spec.Steps)) + } + writeStringField(&b, "OutputFrom", spec.OutputFrom) + if outputHintsSet(spec.Output) { + fmt.Fprintf(&b, "Output: %s,", outputHintsLiteral(spec.Output)) + } + b.WriteByte('}') + return b.String() +} + +func commandSpecLiteral(spec runtime.CommandSpec) string { + var b strings.Builder + b.WriteString("runtime.CommandSpec{") + writeStringField(&b, "Group", spec.Group) + writeStringField(&b, "Use", spec.Use) + writeStringSliceField(&b, "Aliases", spec.Aliases) + if len(spec.Shortcuts) > 0 { + fmt.Fprintf(&b, "Shortcuts: %s,", commandShortcutsLiteral(spec.Shortcuts)) + } + writeStringField(&b, "Short", spec.Short) + writeStringField(&b, "Long", spec.Long) + writeStringField(&b, "Example", spec.Example) + if len(spec.Examples) > 0 { + fmt.Fprintf(&b, "Examples: %s,", commandExamplesLiteral(spec.Examples)) + } + writeStringField(&b, "OperationID", spec.OperationID) + writeBoolField(&b, "Hidden", spec.Hidden) + writeBoolField(&b, "Deprecated", spec.Deprecated) + writeStringField(&b, "Method", spec.Method) + writeStringField(&b, "PathTpl", spec.PathTpl) + writeStringField(&b, "DefaultHostname", spec.DefaultHostname) + if len(spec.Params) > 0 { + fmt.Fprintf(&b, "Params: %s,", paramSpecsLiteral(spec.Params)) + } + if spec.RequestBody != nil { + fmt.Fprintf(&b, "RequestBody: %s,", requestBodyLiteral(spec.RequestBody)) + } + if outputHintsSet(spec.Output) { + fmt.Fprintf(&b, "Output: %s,", outputHintsLiteral(spec.Output)) + } + if spec.Security != nil { + fmt.Fprintf(&b, "Security: %s,", securityHintLiteral(spec.Security)) + } + writeStringSliceField(&b, "Notes", spec.Notes) + writeStringSliceField(&b, "Prerequisites", spec.Prerequisites) + if len(spec.KnownErrors) > 0 { + fmt.Fprintf(&b, "KnownErrors: %s,", knownErrorsLiteral(spec.KnownErrors)) + } + b.WriteByte('}') + return b.String() +} + +func workflowStepSpecsLiteral(steps []runtime.WorkflowStepSpec) string { + var b strings.Builder + b.WriteString("[]runtime.WorkflowStepSpec{") + for _, step := range steps { + b.WriteString("runtime.WorkflowStepSpec{") + writeStringField(&b, "ID", step.ID) + fmt.Fprintf(&b, "Operation: %s,", commandSpecLiteral(step.Operation)) + if len(step.Params) > 0 { + fmt.Fprintf(&b, "Params: %s,", stringMapLiteral(step.Params)) + } + if len(step.BodySets) > 0 { + fmt.Fprintf(&b, "BodySets: %s,", workflowValuesLiteral(step.BodySets)) + } + if len(step.BodyStringSets) > 0 { + fmt.Fprintf(&b, "BodyStringSets: %s,", workflowValuesLiteral(step.BodyStringSets)) + } + b.WriteString("},") + } + b.WriteByte('}') + return b.String() +} + +func workflowValuesLiteral(values []runtime.WorkflowValue) string { + var b strings.Builder + b.WriteString("[]runtime.WorkflowValue{") + for _, value := range values { + b.WriteString("runtime.WorkflowValue{") + writeStringField(&b, "Name", value.Name) + writeStringField(&b, "Value", value.Value) + b.WriteString("},") + } + b.WriteByte('}') + return b.String() +} + +func paramSpecsLiteral(params []runtime.ParamSpec) string { + var b strings.Builder + b.WriteString("[]runtime.ParamSpec{") + for _, param := range params { + b.WriteString("runtime.ParamSpec{") + writeStringField(&b, "Name", param.Name) + writeStringField(&b, "Flag", param.Flag) + writeStringField(&b, "In", param.In) + writeStringField(&b, "GoType", param.GoType) + writeStringField(&b, "Help", param.Help) + writeBoolField(&b, "Required", param.Required) + writeStringField(&b, "Default", param.Default) + writeStringSliceField(&b, "Enum", param.Enum) + writeStringField(&b, "Format", param.Format) + writeBoolField(&b, "Deprecated", param.Deprecated) + b.WriteString("},") + } + b.WriteByte('}') + return b.String() +} + +func commandShortcutsLiteral(shortcuts []runtime.CommandShortcut) string { + var b strings.Builder + b.WriteString("[]runtime.CommandShortcut{") + for _, shortcut := range shortcuts { + b.WriteString("runtime.CommandShortcut{") + writeStringField(&b, "Use", shortcut.Use) + if len(shortcut.Params) > 0 { + fmt.Fprintf(&b, "Params: %s,", stringMapLiteral(shortcut.Params)) + } + b.WriteString("},") + } + b.WriteByte('}') + return b.String() +} + +func commandExamplesLiteral(examples []runtime.CommandExample) string { + var b strings.Builder + b.WriteString("[]runtime.CommandExample{") + for _, example := range examples { + b.WriteString("runtime.CommandExample{") + writeStringField(&b, "Summary", example.Summary) + writeStringField(&b, "Command", example.Command) + if len(example.BodyShape) > 0 { + fmt.Fprintf(&b, "BodyShape: []byte(%q),", string(example.BodyShape)) + } + if example.OutputHints != nil { + fmt.Fprintf(&b, "OutputHints: %s,", exampleOutputHintsLiteral(example.OutputHints)) + } + writeStringSliceField(&b, "FollowUpCommands", example.FollowUpCommands) + b.WriteString("},") + } + b.WriteByte('}') + return b.String() +} + +func exampleOutputHintsLiteral(hints *runtime.ExampleOutputHints) string { + var b strings.Builder + b.WriteString("&runtime.ExampleOutputHints{") + writeStringField(&b, "IDPath", hints.IDPath) + writeStringField(&b, "ListPath", hints.ListPath) + b.WriteByte('}') + return b.String() +} + +func requestBodyLiteral(body *runtime.RequestBody) string { + var b strings.Builder + b.WriteString("&runtime.RequestBody{") + writeBoolField(&b, "Required", body.Required) + writeStringField(&b, "MediaType", body.MediaType) + if body.Schema != nil { + fmt.Fprintf(&b, "Schema: %s,", schemaLiteral(body.Schema)) + } + writeStringField(&b, "Template", body.Template) + writeStringField(&b, "MergePath", body.MergePath) + b.WriteByte('}') + return b.String() +} + +func outputHintsLiteral(hints runtime.OutputHints) string { + var b strings.Builder + b.WriteString("runtime.OutputHints{") + writeStringField(&b, "ListPath", hints.ListPath) + writeStringSliceField(&b, "DefaultColumns", hints.DefaultColumns) + writeStringField(&b, "ResponseMediaType", hints.ResponseMediaType) + if hints.Pagination != nil { + fmt.Fprintf(&b, "Pagination: %s,", paginationHintLiteral(hints.Pagination)) + } + if hints.Streaming != nil { + fmt.Fprintf(&b, "Streaming: %s,", streamingHintLiteral(hints.Streaming)) + } + b.WriteByte('}') + return b.String() +} + +func paginationHintLiteral(hint *runtime.PaginationHint) string { + var b strings.Builder + b.WriteString("&runtime.PaginationHint{") + writeStringField(&b, "Strategy", hint.Strategy) + writeStringField(&b, "TokenParam", hint.TokenParam) + writeStringField(&b, "TokenField", hint.TokenField) + writeStringField(&b, "LimitParam", hint.LimitParam) + b.WriteByte('}') + return b.String() +} + +func streamingHintLiteral(hint *runtime.StreamingHint) string { + var b strings.Builder + b.WriteString("&runtime.StreamingHint{") + writeStringField(&b, "Strategy", hint.Strategy) + b.WriteByte('}') + return b.String() +} + +func securityHintLiteral(hint *runtime.SecurityHint) string { + var b strings.Builder + b.WriteString("&runtime.SecurityHint{") + writeBoolField(&b, "Public", hint.Public) + writeStringSliceField(&b, "Scopes", hint.Scopes) + b.WriteByte('}') + return b.String() +} + +func knownErrorsLiteral(errors []runtime.KnownError) string { + var b strings.Builder + b.WriteString("[]runtime.KnownError{") + for _, known := range errors { + b.WriteString("runtime.KnownError{") + if known.Status != 0 { + fmt.Fprintf(&b, "Status: %d,", known.Status) + } + writeStringField(&b, "Cause", known.Cause) + b.WriteString("},") + } + b.WriteByte('}') + return b.String() +} + +func outputHintsSet(hints runtime.OutputHints) bool { + return hints.ListPath != "" || len(hints.DefaultColumns) > 0 || hints.ResponseMediaType != "" || hints.Pagination != nil || hints.Streaming != nil +} + +func writeStringField(b *strings.Builder, name, value string) { + if value != "" { + fmt.Fprintf(b, "%s: %q,", name, value) + } +} + +func writeBoolField(b *strings.Builder, name string, value bool) { + if value { + fmt.Fprintf(b, "%s: true,", name) + } +} + +func writeStringSliceField(b *strings.Builder, name string, values []string) { + if len(values) == 0 { + return + } + fmt.Fprintf(b, "%s: ", name) + b.WriteString("[]string{") + for _, value := range values { + fmt.Fprintf(b, "%q,", value) + } + b.WriteString("},") +} + func writeSchemaLiteral(b *strings.Builder, s *runtime.SchemaSpec) { if s == nil { b.WriteString("nil") @@ -793,6 +1093,9 @@ import ( {{- range .Modules}} {{.Name}} "{{$.Prefix}}{{.Name}}" {{- end}} +{{- if .Workflows}} + lathegeneratedworkflows "{{$.Prefix}}workflows" +{{- end}} {{- if .SkillBundle}} lathegeneratedskillbundle "{{$.Prefix}}skillbundle" {{- end}} @@ -813,6 +1116,11 @@ func MountModules(root *cobra.Command) error { return err } {{- end}} +{{- if .Workflows}} + if err := lathegeneratedworkflows.Mount(root); err != nil { + return err + } +{{- end}} {{- if .SkillBundle}} latheruntime.AttachCapability(root, latheruntime.CapabilitySkillBundle) root.AddCommand(lathekitupcobra.NewSkillCommand(lathekitupcobra.Options{ @@ -824,6 +1132,29 @@ func MountModules(root *cobra.Command) error { } `)) +var workflowsTmpl = template.Must(template.New("workflows").Funcs(template.FuncMap{ + "workflowSpecLiteral": workflowSpecLiteral, +}).Parse(`// Code generated by lathe codegen. DO NOT EDIT. + +package workflows + +import ( + "github.com/spf13/cobra" + + "{{.RuntimePkg}}" +) + +func Mount(root *cobra.Command) error { + return runtime.BuildWorkflows(root, Specs) +} + +var Specs = []runtime.WorkflowSpec{ +{{- range .Specs}} + {{workflowSpecLiteral .}}, +{{- end}} +} +`)) + var skillBundleTmpl = template.Must(template.New("skillbundle").Parse(`// Code generated by lathe codegen. DO NOT EDIT. package skillbundle diff --git a/internal/codegen/render/render_test.go b/internal/codegen/render/render_test.go index f88fbfc..bec8527 100644 --- a/internal/codegen/render/render_test.go +++ b/internal/codegen/render/render_test.go @@ -45,6 +45,15 @@ func generatedModules(t *testing.T) string { return string(out) } +func generatedWorkflows(t *testing.T) string { + t.Helper() + out, err := os.ReadFile("internal/generated/workflows/workflows_gen.go") + if err != nil { + t.Fatal(err) + } + return string(out) +} + func TestRenderModule_AppliesOverlay(t *testing.T) { chdirWithGoMod(t) @@ -118,6 +127,60 @@ func TestRenderModule_AppliesOverlay(t *testing.T) { } } +func TestRenderWorkflows_EmitsPointerFieldLiterals(t *testing.T) { + chdirWithGeneratedRoot(t) + + specs := []runtime.WorkflowSpec{{ + Use: "doctor", + Steps: []runtime.WorkflowStepSpec{{ + ID: "create", + Operation: runtime.CommandSpec{ + Group: "Apps", + Use: "create-app", + Short: "Create an app.", + Method: "POST", + PathTpl: "/apps", + OperationID: "Apps_Create", + RequestBody: &runtime.RequestBody{ + Required: true, + MediaType: "application/json", + Schema: &runtime.SchemaSpec{ + Type: "object", + Required: []string{"name"}, + }, + }, + Output: runtime.OutputHints{ + Pagination: &runtime.PaginationHint{ + Strategy: "token", + TokenParam: "page_token", + }, + }, + Security: &runtime.SecurityHint{Scopes: []string{"apps:write"}}, + }, + }}, + }} + + if err := RenderWorkflows(specs); err != nil { + t.Fatalf("RenderWorkflows: %v", err) + } + got := generatedWorkflows(t) + for _, want := range []string{ + `RequestBody: &runtime.RequestBody{`, + `Schema: &runtime.SchemaSpec{`, + `Pagination: &runtime.PaginationHint{`, + `Security: &runtime.SecurityHint{`, + } { + if !strings.Contains(got, want) { + t.Errorf("output missing %q", want) + } + } + for _, bad := range []string{"(*runtime.RequestBody)", "(*runtime.SecurityHint)", "(*runtime.PaginationHint)", "(0x"} { + if strings.Contains(got, bad) { + t.Fatalf("workflow literal contains pointer address %q:\n%s", bad, got) + } + } +} + func TestRenderModule_EmitsRequestBodyEnvelope(t *testing.T) { chdirWithGoMod(t) diff --git a/internal/lathecmd/lathecmd.go b/internal/lathecmd/lathecmd.go index db0dbd0..1be41ab 100644 --- a/internal/lathecmd/lathecmd.go +++ b/internal/lathecmd/lathecmd.go @@ -250,6 +250,11 @@ func buildGeneratedApp(cfg *sourceconfig.Config, overlays map[string]overlay.Mod generated.Skill.Modules = append(generated.Skill.Modules, render.SkillModule{Source: src, State: state, Specs: specs}) } } + workflows, err := buildWorkflowSpecs(manifest, generated.Modules, shortcutRootNames) + if err != nil { + return nil, err + } + generated.Workflows = workflows return generated, nil } diff --git a/internal/lathecmd/lathecmd_test.go b/internal/lathecmd/lathecmd_test.go index c166941..f66efe7 100644 --- a/internal/lathecmd/lathecmd_test.go +++ b/internal/lathecmd/lathecmd_test.go @@ -142,6 +142,132 @@ func TestRun_CodegenSubcommandGeneratesSkillDirectoryByDefault(t *testing.T) { } } +func TestRunCodegen_GeneratesWorkflowCommands(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + seedCodegenProject(t, true) + writeCodegenFile(t, "cli.yaml", `cli: + name: acmectl + short: Acme CLI +workflow: + commands: + - use: doctor + short: Check API health + steps: + - id: users + uses: acme.Users_List + output: + from: ${steps.users} +`) + + if err := RunCodegen([]string{"-sources", "specs/sources.yaml", "-cache", ".cache"}, &bytes.Buffer{}); err != nil { + t.Fatalf("run: %v", err) + } + + workflows := readCodegenFile(t, "internal/generated/workflows/workflows_gen.go") + for _, want := range []string{`Use: "doctor"`, `ID: "users"`, `OperationID: "Users_List"`} { + if !strings.Contains(workflows, want) { + t.Fatalf("workflows_gen missing %q:\n%s", want, workflows) + } + } + modulesGen := readCodegenFile(t, "internal/generated/modules_gen.go") + if !strings.Contains(modulesGen, "lathegeneratedworkflows.Mount(root)") { + t.Fatalf("modules_gen should mount workflows:\n%s", modulesGen) + } +} + +func TestRunCodegen_WorkflowRejectsUnknownStepParam(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + seedCodegenProject(t, true) + writeCodegenFile(t, "cli.yaml", `cli: + name: acmectl + short: Acme CLI +workflow: + commands: + - use: doctor + steps: + - id: users + uses: acme.Users_List + params: + missing: value +`) + + err := RunCodegen([]string{"-sources", "specs/sources.yaml", "-cache", ".cache"}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), `param "missing"`) { + t.Fatalf("error = %v", err) + } +} + +func TestRunCodegen_WorkflowRejectsMalformedReference(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + seedCodegenProject(t, true) + writeCodegenFile(t, "cli.yaml", `cli: + name: acmectl + short: Acme CLI +workflow: + commands: + - use: doctor + inputs: + - name: tenant_id + steps: + - id: users + uses: acme.Users_List + output: + from: ${input.tenant_id +`) + + err := RunCodegen([]string{"-sources", "specs/sources.yaml", "-cache", ".cache"}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), `unterminated reference`) { + t.Fatalf("error = %v", err) + } +} + +func TestRunCodegen_WorkflowRejectsAliasRootConflict(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + seedCodegenProject(t, true) + writeCodegenFile(t, "cli.yaml", `cli: + name: acmectl + short: Acme CLI +workflow: + commands: + - use: doctor + aliases: [users] + steps: + - id: users + uses: acme.Users_List +`) + + err := RunCodegen([]string{"-sources", "specs/sources.yaml", "-cache", ".cache"}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), `alias "users"`) { + t.Fatalf("error = %v", err) + } +} + +func TestRunCodegen_WorkflowRejectsReservedAlias(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + seedCodegenProject(t, true) + writeCodegenFile(t, "cli.yaml", `cli: + name: acmectl + short: Acme CLI +workflow: + commands: + - use: doctor + aliases: [auth] + steps: + - id: users + uses: acme.Users_List +`) + + err := RunCodegen([]string{"-sources", "specs/sources.yaml", "-cache", ".cache"}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), `reserved root command`) { + t.Fatalf("error = %v", err) + } +} + func TestRunCodegen_CommandPathFlatRewritesGeneratedExamples(t *testing.T) { dir := t.TempDir() t.Chdir(dir) diff --git a/internal/lathecmd/workflow.go b/internal/lathecmd/workflow.go new file mode 100644 index 0000000..e144f33 --- /dev/null +++ b/internal/lathecmd/workflow.go @@ -0,0 +1,307 @@ +package lathecmd + +import ( + "fmt" + "maps" + "sort" + "strings" + + "github.com/lathe-cli/lathe/internal/codegen/app" + "github.com/lathe-cli/lathe/pkg/config" + "github.com/lathe-cli/lathe/pkg/runtime" +) + +func buildWorkflowSpecs(manifest *config.Manifest, modules []app.Module, shortcutRootNames []string) ([]runtime.WorkflowSpec, error) { + if len(manifest.Workflow.Commands) == 0 { + return nil, nil + } + lookup := workflowOperationLookup(modules) + rootNames := workflowRootNames(modules, shortcutRootNames) + out := make([]runtime.WorkflowSpec, 0, len(manifest.Workflow.Commands)) + for _, command := range manifest.Workflow.Commands { + if rootNames[workflowLookupKey(command.Use)] { + return nil, fmt.Errorf("workflow command %q conflicts with an existing generated root command", command.Use) + } + for _, alias := range command.Aliases { + if rootNames[workflowLookupKey(alias)] { + return nil, fmt.Errorf("workflow command %q alias %q conflicts with an existing generated root command", command.Use, alias) + } + } + rootNames[workflowLookupKey(command.Use)] = true + for _, alias := range command.Aliases { + rootNames[workflowLookupKey(alias)] = true + } + inputNames := workflowInputNames(command.Inputs) + seenSteps := map[string]bool{} + workflow := runtime.WorkflowSpec{ + Use: command.Use, + Aliases: append([]string(nil), command.Aliases...), + Short: command.Short, + Long: command.Long, + Example: command.Example, + Hidden: command.Hidden, + Deprecated: command.Deprecated, + Params: workflowInputs(command.Inputs), + OutputFrom: command.Output.From, + Output: runtime.OutputHints{ + ListPath: command.Output.ListPath, + DefaultColumns: append([]string(nil), command.Output.DefaultColumns...), + ResponseMediaType: command.Output.ResponseMediaType, + }, + } + for _, step := range command.Steps { + ref, err := lookup.resolve(step.Uses) + if err != nil { + return nil, fmt.Errorf("workflow command %q step %q: %w", command.Use, step.ID, err) + } + if err := validateWorkflowStepParams(step, ref); err != nil { + return nil, fmt.Errorf("workflow command %q step %q: %w", command.Use, step.ID, err) + } + if err := validateWorkflowStepRefs(step, inputNames, seenSteps); err != nil { + return nil, fmt.Errorf("workflow command %q step %q: %w", command.Use, step.ID, err) + } + workflow.Steps = append(workflow.Steps, runtime.WorkflowStepSpec{ + ID: step.ID, + Operation: ref, + Params: maps.Clone(step.Params), + BodySets: workflowValues(step.Set), + BodyStringSets: workflowValues(step.SetStr), + }) + seenSteps[step.ID] = true + } + if err := validateWorkflowRefs(command.Output.From, inputNames, seenSteps); err != nil { + return nil, fmt.Errorf("workflow command %q output.from: %w", command.Use, err) + } + out = append(out, workflow) + } + return out, nil +} + +type workflowLookup struct { + refs map[string]runtime.CommandSpec + ambiguous map[string]bool +} + +func workflowOperationLookup(modules []app.Module) workflowLookup { + lookup := workflowLookup{ + refs: map[string]runtime.CommandSpec{}, + ambiguous: map[string]bool{}, + } + for _, module := range modules { + for _, spec := range module.Specs { + if spec.OperationID != "" { + lookup.add(spec.OperationID, spec) + lookup.add(module.Source+"."+spec.OperationID, spec) + lookup.add(module.CLIName+"."+spec.OperationID, spec) + } + group := workflowCommandSegment(spec.Group) + use := workflowCommandSegment(spec.Use) + if group != "" && use != "" { + lookup.add(module.Source+" "+group+" "+use, spec) + lookup.add(module.CLIName+" "+group+" "+use, spec) + lookup.add(module.Source+"."+group+"."+use, spec) + lookup.add(module.CLIName+"."+group+"."+use, spec) + if module.Flat { + lookup.add(group+" "+use, spec) + lookup.add(group+"."+use, spec) + } + } + } + } + return lookup +} + +func (l workflowLookup) add(key string, spec runtime.CommandSpec) { + key = workflowLookupKey(key) + if key == "" { + return + } + if existing, exists := l.refs[key]; exists { + if existing.OperationID == spec.OperationID && + existing.Method == spec.Method && + existing.PathTpl == spec.PathTpl && + existing.Use == spec.Use && + existing.Group == spec.Group { + return + } + l.ambiguous[key] = true + return + } + l.refs[key] = spec +} + +func (l workflowLookup) resolve(raw string) (runtime.CommandSpec, error) { + key := workflowLookupKey(raw) + if l.ambiguous[key] { + return runtime.CommandSpec{}, fmt.Errorf("operation reference %q is ambiguous", raw) + } + ref, ok := l.refs[key] + if !ok { + return runtime.CommandSpec{}, fmt.Errorf("operation reference %q was not found", raw) + } + return ref, nil +} + +func workflowLookupKey(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + raw = strings.Join(strings.Fields(raw), " ") + return raw +} + +func workflowCommandSegment(raw string) string { + fields := strings.Fields(strings.ToLower(strings.TrimSpace(raw))) + if len(fields) == 0 { + return "" + } + return fields[0] +} + +func workflowRootNames(modules []app.Module, shortcuts []string) map[string]bool { + names := map[string]bool{} + for _, shortcut := range shortcuts { + names[workflowLookupKey(shortcut)] = true + } + for _, module := range modules { + if !module.Flat { + names[workflowLookupKey(module.CLIName)] = true + continue + } + for _, spec := range module.Specs { + name := workflowCommandSegment(spec.Group) + if name != "" { + names[workflowLookupKey(name)] = true + } + } + } + return names +} + +func workflowInputNames(inputs []config.WorkflowInput) map[string]bool { + names := map[string]bool{} + for _, input := range inputs { + names[input.Name] = true + names[input.Flag] = true + } + return names +} + +func validateWorkflowStepParams(step config.WorkflowStep, spec runtime.CommandSpec) error { + if len(step.Params) == 0 { + return nil + } + allowed := map[string]bool{} + for _, param := range spec.Params { + allowed[param.Name] = true + allowed[param.Flag] = true + } + for key := range step.Params { + if !allowed[key] { + return fmt.Errorf("param %q does not match operation %q", key, workflowOperationName(spec)) + } + } + return nil +} + +func workflowOperationName(spec runtime.CommandSpec) string { + if spec.OperationID != "" { + return spec.OperationID + } + return strings.TrimSpace(spec.Group + " " + spec.Use) +} + +func validateWorkflowStepRefs(step config.WorkflowStep, inputs map[string]bool, steps map[string]bool) error { + for key, expr := range step.Params { + if err := validateWorkflowRefs(expr, inputs, steps); err != nil { + return fmt.Errorf("param %q: %w", key, err) + } + } + for key, expr := range step.Set { + if err := validateWorkflowRefs(expr, inputs, steps); err != nil { + return fmt.Errorf("set %q: %w", key, err) + } + } + for key, expr := range step.SetStr { + if err := validateWorkflowRefs(expr, inputs, steps); err != nil { + return fmt.Errorf("set_str %q: %w", key, err) + } + } + return nil +} + +func validateWorkflowRefs(expr string, inputs map[string]bool, steps map[string]bool) error { + refs, err := workflowRefs(expr) + if err != nil { + return err + } + for _, ref := range refs { + switch { + case strings.HasPrefix(ref, "input."): + name := strings.TrimPrefix(ref, "input.") + if !inputs[name] { + return fmt.Errorf("unknown input %q", name) + } + case strings.HasPrefix(ref, "steps."): + rest := strings.TrimPrefix(ref, "steps.") + id, _, _ := strings.Cut(rest, ".") + if !steps[id] { + return fmt.Errorf("unknown or forward step %q", id) + } + default: + return fmt.Errorf("unknown reference %q", ref) + } + } + return nil +} + +func workflowRefs(expr string) ([]string, error) { + var refs []string + rest := expr + for { + start := strings.Index(rest, "${") + if start < 0 { + return refs, nil + } + after := rest[start+2:] + end := strings.Index(after, "}") + if end < 0 { + return nil, fmt.Errorf("unterminated reference in %q", expr) + } + refs = append(refs, strings.TrimSpace(after[:end])) + rest = after[end+1:] + } +} + +func workflowInputs(inputs []config.WorkflowInput) []runtime.ParamSpec { + out := make([]runtime.ParamSpec, 0, len(inputs)) + for _, input := range inputs { + out = append(out, runtime.ParamSpec{ + Name: input.Name, + Flag: input.Flag, + In: runtime.InInput, + GoType: input.Type, + Help: input.Help, + Required: input.Required, + Default: input.Default, + Enum: append([]string(nil), input.Enum...), + Format: input.Format, + Deprecated: input.Deprecated, + }) + } + return out +} + +func workflowValues(values map[string]string) []runtime.WorkflowValue { + if len(values) == 0 { + return nil + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]runtime.WorkflowValue, 0, len(keys)) + for _, key := range keys { + out = append(out, runtime.WorkflowValue{Name: key, Value: values[key]}) + } + return out +} diff --git a/pkg/config/manifest.go b/pkg/config/manifest.go index 142e88d..74b501d 100644 --- a/pkg/config/manifest.go +++ b/pkg/config/manifest.go @@ -9,10 +9,11 @@ import ( ) type Manifest struct { - CLI CLIInfo `yaml:"cli"` - Auth AuthInfo `yaml:"auth"` - Update UpdateInfo `yaml:"update,omitempty"` - Skill SkillInfo `yaml:"skill,omitempty"` + CLI CLIInfo `yaml:"cli"` + Auth AuthInfo `yaml:"auth"` + Update UpdateInfo `yaml:"update,omitempty"` + Skill SkillInfo `yaml:"skill,omitempty"` + Workflow WorkflowInfo `yaml:"workflow,omitempty"` } type CLIInfo struct { @@ -37,6 +38,51 @@ type SkillInfo struct { Bundle bool `yaml:"bundle,omitempty"` } +type WorkflowInfo struct { + Version int `yaml:"version,omitempty"` + Commands []WorkflowCommand `yaml:"commands,omitempty"` +} + +type WorkflowCommand struct { + Use string `yaml:"use"` + Aliases []string `yaml:"aliases,omitempty"` + Short string `yaml:"short,omitempty"` + Long string `yaml:"long,omitempty"` + Example string `yaml:"example,omitempty"` + Hidden bool `yaml:"hidden,omitempty"` + Deprecated bool `yaml:"deprecated,omitempty"` + Inputs []WorkflowInput `yaml:"inputs,omitempty"` + Steps []WorkflowStep `yaml:"steps,omitempty"` + Output WorkflowOutput `yaml:"output,omitempty"` +} + +type WorkflowInput struct { + Name string `yaml:"name"` + Flag string `yaml:"flag,omitempty"` + Type string `yaml:"type,omitempty"` + Help string `yaml:"help,omitempty"` + Required bool `yaml:"required,omitempty"` + Default string `yaml:"default,omitempty"` + Enum []string `yaml:"enum,omitempty"` + Format string `yaml:"format,omitempty"` + Deprecated bool `yaml:"deprecated,omitempty"` +} + +type WorkflowStep struct { + ID string `yaml:"id"` + Uses string `yaml:"uses"` + Params map[string]string `yaml:"params,omitempty"` + Set map[string]string `yaml:"set,omitempty"` + SetStr map[string]string `yaml:"set_str,omitempty"` +} + +type WorkflowOutput struct { + From string `yaml:"from,omitempty"` + ListPath string `yaml:"list_path,omitempty"` + DefaultColumns []string `yaml:"default_columns,omitempty"` + ResponseMediaType string `yaml:"response_media_type,omitempty"` +} + type AuthLogin struct { Type string `yaml:"type"` StartPath string `yaml:"start_path"` @@ -84,6 +130,9 @@ func Load(bytes []byte) (*Manifest, error) { if m.CLI.Name == "" { return nil, fmt.Errorf("cli.name is required") } + if err := normalizeWorkflow(&m.Workflow); err != nil { + return nil, err + } if m.Update.GitHub != nil { m.Update.GitHub.Owner = strings.TrimSpace(m.Update.GitHub.Owner) m.Update.GitHub.Repo = strings.TrimSpace(m.Update.GitHub.Repo) @@ -132,6 +181,97 @@ func Load(bytes []byte) (*Manifest, error) { return &m, nil } +func normalizeWorkflow(workflow *WorkflowInfo) error { + if len(workflow.Commands) == 0 { + workflow.Version = 0 + return nil + } + if workflow.Version == 0 { + workflow.Version = 1 + } + if workflow.Version != 1 { + return fmt.Errorf("workflow.version must be 1") + } + seen := map[string]bool{} + for i := range workflow.Commands { + cmd := &workflow.Commands[i] + cmd.Use = strings.TrimSpace(cmd.Use) + if cmd.Use == "" || len(strings.Fields(cmd.Use)) != 1 { + return fmt.Errorf("workflow.commands[%d].use must be a single command name", i) + } + if seen[cmd.Use] { + return fmt.Errorf("workflow command %q is declared more than once", cmd.Use) + } + seen[cmd.Use] = true + inputNames := map[string]bool{} + inputFlags := map[string]bool{} + for j := range cmd.Inputs { + input := &cmd.Inputs[j] + input.Name = strings.TrimSpace(input.Name) + input.Flag = strings.TrimSpace(input.Flag) + input.Type = strings.TrimSpace(input.Type) + if input.Name == "" { + return fmt.Errorf("workflow command %q input %d name is required", cmd.Use, j) + } + if input.Flag == "" { + input.Flag = workflowInputFlag(input.Name) + } + if input.Type == "" { + input.Type = "string" + } + if !validWorkflowInputType(input.Type) { + return fmt.Errorf("workflow command %q input %q type %q is not supported", cmd.Use, input.Name, input.Type) + } + if inputNames[input.Name] { + return fmt.Errorf("workflow command %q input name %q is declared more than once", cmd.Use, input.Name) + } + if inputFlags[input.Flag] { + return fmt.Errorf("workflow command %q input flag %q is declared more than once", cmd.Use, input.Flag) + } + inputNames[input.Name] = true + inputFlags[input.Flag] = true + } + if len(cmd.Steps) == 0 { + return fmt.Errorf("workflow command %q must have at least one step", cmd.Use) + } + stepIDs := map[string]bool{} + for j := range cmd.Steps { + step := &cmd.Steps[j] + step.ID = strings.TrimSpace(step.ID) + step.Uses = strings.TrimSpace(step.Uses) + if step.ID == "" { + return fmt.Errorf("workflow command %q steps[%d].id is required", cmd.Use, j) + } + if strings.Contains(step.ID, ".") { + return fmt.Errorf("workflow command %q step id %q must not contain dots", cmd.Use, step.ID) + } + if stepIDs[step.ID] { + return fmt.Errorf("workflow command %q step %q is declared more than once", cmd.Use, step.ID) + } + stepIDs[step.ID] = true + if step.Uses == "" { + return fmt.Errorf("workflow command %q step %q uses is required", cmd.Use, step.ID) + } + } + } + return nil +} + +func workflowInputFlag(name string) string { + name = strings.ReplaceAll(name, "_", "-") + name = strings.ReplaceAll(name, ".", "-") + return name +} + +func validWorkflowInputType(value string) bool { + switch value { + case "string", "int64", "float64", "bool", "[]string", "[]int64", "[]float64", "[]bool": + return true + default: + return false + } +} + var ( boundMu sync.RWMutex bound *Manifest diff --git a/pkg/config/manifest_test.go b/pkg/config/manifest_test.go index 7d8e44b..639b834 100644 --- a/pkg/config/manifest_test.go +++ b/pkg/config/manifest_test.go @@ -1,6 +1,9 @@ package config -import "testing" +import ( + "strings" + "testing" +) func TestLoad_FullSpec(t *testing.T) { data := []byte(` @@ -157,6 +160,57 @@ cli: } } +func TestLoad_WorkflowRejectsDuplicateInputs(t *testing.T) { + tests := []struct { + name string + yaml string + want string + }{ + { + name: "name", + want: "input name", + yaml: ` +cli: + name: demo +workflow: + commands: + - use: doctor + inputs: + - name: app_id + - name: app_id + steps: + - id: health + uses: acme.getHealth +`, + }, + { + name: "flag", + want: "input flag", + yaml: ` +cli: + name: demo +workflow: + commands: + - use: doctor + inputs: + - name: app_id + - name: app.id + steps: + - id: health + uses: acme.getHealth +`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := Load([]byte(tc.yaml)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } +} + func TestLoad_AuthLoginValidation(t *testing.T) { tests := []struct { name string diff --git a/pkg/lathe/verify.go b/pkg/lathe/verify.go index 35d1b20..d6ba48b 100644 --- a/pkg/lathe/verify.go +++ b/pkg/lathe/verify.go @@ -75,6 +75,9 @@ func verifyGenerated(root *cobra.Command, m *config.Manifest) verifyReport { if runtime.HasCapability(root, runtime.CapabilitySkillBundle) { report.add("skill_install", verifySkillInstall(root, m)) } + if runtime.HasCapability(root, runtime.CapabilityWorkflowDSL) { + report.add("workflow_contract", verifyWorkflowContract(runtime.BuildCatalog(root, catalogOptions(m, true)))) + } return report } @@ -176,6 +179,37 @@ func verifyCatalogEntry(root *cobra.Command, m *config.Manifest, entry runtime.C return nil } +func verifyWorkflowContract(catalog runtime.Catalog) error { + count := 0 + for _, entry := range catalog.Commands { + if entry.Kind != "workflow" { + continue + } + count++ + if entry.Workflow == nil { + return fmt.Errorf("workflow command %q missing workflow metadata", strings.Join(entry.Path, " ")) + } + if entry.Workflow.DSL != "lathe.workflow.v1" { + return fmt.Errorf("workflow command %q DSL = %q", strings.Join(entry.Path, " "), entry.Workflow.DSL) + } + if len(entry.Workflow.Steps) == 0 { + return fmt.Errorf("workflow command %q has no steps", strings.Join(entry.Path, " ")) + } + for _, step := range entry.Workflow.Steps { + if step.ID == "" { + return fmt.Errorf("workflow command %q has a step with empty id", strings.Join(entry.Path, " ")) + } + if step.HTTP.Method == "" || step.HTTP.PathTemplate == "" { + return fmt.Errorf("workflow command %q step %q missing operation HTTP metadata", strings.Join(entry.Path, " "), step.ID) + } + } + } + if count == 0 { + return errors.New("workflow capability is present but no workflow commands are cataloged") + } + return nil +} + func isJSONBody(mediaType string) bool { mt := normalizedMediaType(mediaType) return mt == "" || mt == "application/json" || strings.HasSuffix(mt, "+json") diff --git a/pkg/lathe/verify_test.go b/pkg/lathe/verify_test.go index ccaeb03..cb9073f 100644 --- a/pkg/lathe/verify_test.go +++ b/pkg/lathe/verify_test.go @@ -150,6 +150,41 @@ func TestVerifyGeneratedSkillInstall(t *testing.T) { } } +func TestVerifyGeneratedHiddenWorkflowContract(t *testing.T) { + root := NewApp(testManifest()) + if err := runtime.Build(root, "demo", []runtime.CommandSpec{{ + Group: "Users", + Use: "get-user", + Method: "GET", + PathTpl: "/users/{id}", + }}); err != nil { + t.Fatal(err) + } + if err := runtime.BuildWorkflows(root, []runtime.WorkflowSpec{{ + Use: "doctor", + Hidden: true, + Steps: []runtime.WorkflowStepSpec{{ + ID: "health", + Operation: runtime.CommandSpec{ + OperationID: "Health_Check", + Method: "GET", + PathTpl: "/health", + Security: &runtime.SecurityHint{Public: true}, + }, + }}, + }}); err != nil { + t.Fatal(err) + } + + report := verifyGenerated(root, testManifest()) + if !report.OK { + t.Fatalf("report = %+v", report) + } + if !verifyReportHasCheck(report, "workflow_contract") { + t.Fatalf("report missing workflow_contract: %+v", report.Checks) + } +} + func TestRunVerifyGeneratedFailureReturnsJSONOnly(t *testing.T) { var stdout, stderr bytes.Buffer code := run(RunOptions{ diff --git a/pkg/runtime/build.go b/pkg/runtime/build.go index 1869577..0f1e83e 100644 --- a/pkg/runtime/build.go +++ b/pkg/runtime/build.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io" - "net/url" "os" "strconv" "strings" @@ -121,257 +120,48 @@ func buildCmd(s CommandSpec) *cobra.Command { return err } - for _, p := range s.Params { - if len(p.Enum) == 0 || !flagChangedOrDefault(cmd, p) { - continue - } - raw := flagStringValue(vals[p.Name]) - valid := false - for _, e := range p.Enum { - if raw == e { - valid = true - break - } - } - if !valid { - return fmt.Errorf("invalid value %q for --%s: must be one of %s", - raw, p.Flag, strings.Join(p.Enum, ", ")) - } - } - - path := s.PathTpl - q := url.Values{} - hdrs := map[string]string{} - form := url.Values{} - vars := map[string]any{} - for _, p := range s.Params { - switch p.In { - case InPath: - v := vals[p.Name].(*string) - path = strings.Replace(path, "{"+p.Name+"}", url.PathEscape(*v), 1) - continue - case InHeader: - if !flagChangedOrDefault(cmd, p) { - continue - } - hdrs[p.Name] = *vals[p.Name].(*string) - continue - case InVariable: - if !flagChangedOrDefault(cmd, p) { - continue - } - switch v := vals[p.Name].(type) { - case *int64: - vars[p.Name] = *v - case *float64: - vars[p.Name] = *v - case *bool: - vars[p.Name] = *v - case *[]int64: - vars[p.Name] = *v - case *[]float64: - vars[p.Name] = *v - case *[]bool: - vars[p.Name] = *v - case *[]string: - vars[p.Name] = *v - case *string: - vars[p.Name] = *v - } - continue - case InFormData: - if !flagChangedOrDefault(cmd, p) { - continue - } - switch v := vals[p.Name].(type) { - case *int64: - form.Set(p.Name, strconv.FormatInt(*v, 10)) - case *bool: - form.Set(p.Name, strconv.FormatBool(*v)) - case *string: - form.Set(p.Name, *v) - } - continue - } - if !flagChangedOrDefault(cmd, p) { - continue - } - switch v := vals[p.Name].(type) { - case *int64: - q.Set(p.Name, strconv.FormatInt(*v, 10)) - case *bool: - q.Set(p.Name, strconv.FormatBool(*v)) - case *[]string: - for _, vv := range *v { - q.Add(p.Name, vv) - } - case *string: - q.Set(p.Name, *v) - } - } - if enc := q.Encode(); enc != "" { - path = path + "?" + enc - } - - var body any - if len(form) > 0 { - body = form - } else if s.RequestBody != nil && s.RequestBody.Template != "" { - hasFile := cmd.Flags().Changed("file") - var fileData []byte - if hasFile { - fd, rerr := ReadBody(bodyFile) - if rerr != nil { - return rerr - } - fileData = fd - } - raw, berr := buildEnvelopeBody(s.RequestBody.Template, s.RequestBody.MergePath, vars, bodySets, bodyStringSets, fileData, hasFile) - if berr != nil { - return berr - } - body = raw - } else if s.RequestBody != nil { - switch { - case cmd.Flags().Changed("set") || cmd.Flags().Changed("set-str"): - if !supportsJSONBodyBuilder(s.RequestBody.MediaType) { - return fmt.Errorf("request body media type %s requires --file; --set and --set-str only support JSON request bodies", s.RequestBody.MediaType) - } - raw, berr := buildBodyFromSet(bodySets, bodyStringSets) - if berr != nil { - return berr - } - body = raw - case cmd.Flags().Changed("file"): - raw, rerr := ReadBody(bodyFile) - if rerr != nil { - return rerr - } - body = raw - case s.RequestBody.Required: - if !supportsJSONBodyBuilder(s.RequestBody.MediaType) { - return fmt.Errorf("request body media type %s requires --file", s.RequestBody.MediaType) - } - return fmt.Errorf("request body required: pass --file, --set, or --set-str") + hasFile := s.RequestBody != nil && cmd.Flags().Changed("file") + var fileBody []byte + if hasFile { + fileBody, err = ReadBody(bodyFile) + if err != nil { + return err } } - if err := validateRequiredVariableParams(s, body); err != nil { - return err - } - if body != nil && s.RequestBody != nil && s.RequestBody.MediaType != "" { - hdrs["Content-Type"] = s.RequestBody.MediaType - } if v, err := cmd.Root().PersistentFlags().GetBool("debug"); err == nil && v { clientOpts.Debug = true } clientOpts.UserAgent = cmd.Root().Use - clientOpts.Headers = hdrs - if s.Output.ResponseMediaType != "" { - clientOpts.Accept = s.Output.ResponseMediaType - } - if dryRun { - return writeDryRun(cmd, s, hostname, path, body, clientOpts) + + result, err := InvokeOperation(cmd.Context(), s, OperationInput{ + Values: vals, + Changed: operationChangedFlags(cmd, s.Params), + FileBody: fileBody, + HasFile: hasFile, + BodySets: bodySets, + BodyStringSets: bodyStringSets, + }, OperationOptions{ + Hostname: hostname, + Client: clientOpts, + DryRun: dryRun, + PaginateAll: paginateAll, + MaxPages: maxPages, + Wait: waitPoll, + }) + if err != nil { + return err } - var data []byte - if paginateAll && s.Output.Pagination != nil { - data, err = PaginateAll(cmd.Context(), hostname, s.Method, path, body, clientOpts, *s.Output.Pagination, s.Output.ListPath, maxPages) - if err != nil { - return err - } - } else if waitPoll { - r, rerr := DoRawFull(cmd.Context(), hostname, s.Method, path, body, clientOpts) - if rerr != nil { - return rerr - } - if r.StatusCode == 202 { - if loc := r.Header.Get("Location"); loc != "" { - data, err = PollUntilDone(cmd.Context(), hostname, loc, clientOpts, DefaultPollTimeout) - if err != nil { - return err - } - } else { - data = r.Body - } - } else { - data = r.Body - } - } else { - data, err = DoRaw(cmd.Context(), hostname, s.Method, path, body, clientOpts) - if err != nil { - return err - } + if result.DryRun != nil { + return writeDryRun(*result.DryRun, cmd.OutOrStdout()) } format, _ := cmd.Root().PersistentFlags().GetString("output") - return FormatOutput(data, format, os.Stdout, s.Output) + return FormatOutput(result.Data, format, os.Stdout, s.Output) }, } for i := range s.Params { - p := s.Params[i] - if p.In == InPath { - v := new(string) - vals[p.Name] = v - cmd.Flags().StringVar(v, p.Flag, p.Default, p.Help) - addSafeInputFlags(cmd, p) - if p.Default == "" && !isSensitiveStringParam(p) { - _ = cmd.MarkFlagRequired(p.Flag) - } - if p.Deprecated { - _ = cmd.Flags().MarkDeprecated(p.Flag, "this flag is deprecated") - } - continue - } - switch p.GoType { - case "int64": - v := new(int64) - vals[p.Name] = v - var def int64 - if p.Default != "" { - def, _ = strconv.ParseInt(p.Default, 10, 64) - } - cmd.Flags().Int64Var(v, p.Flag, def, p.Help) - case "float64": - v := new(float64) - vals[p.Name] = v - var def float64 - if p.Default != "" { - def, _ = strconv.ParseFloat(p.Default, 64) - } - cmd.Flags().Float64Var(v, p.Flag, def, p.Help) - case "bool": - v := new(bool) - vals[p.Name] = v - def := p.Default == "true" - cmd.Flags().BoolVar(v, p.Flag, def, p.Help) - case "[]int64": - v := new([]int64) - vals[p.Name] = v - cmd.Flags().Int64SliceVar(v, p.Flag, nil, p.Help) - case "[]float64": - v := new([]float64) - vals[p.Name] = v - cmd.Flags().Float64SliceVar(v, p.Flag, nil, p.Help) - case "[]bool": - v := new([]bool) - vals[p.Name] = v - cmd.Flags().BoolSliceVar(v, p.Flag, nil, p.Help) - case "[]string": - v := new([]string) - vals[p.Name] = v - cmd.Flags().StringSliceVar(v, p.Flag, nil, p.Help) - default: - v := new(string) - vals[p.Name] = v - cmd.Flags().StringVar(v, p.Flag, p.Default, p.Help) - addSafeInputFlags(cmd, p) - } - if p.Required && p.Default == "" && (p.In != InVariable || s.RequestBody == nil) && !isSensitiveStringParam(p) { - _ = cmd.MarkFlagRequired(p.Flag) - } - if p.Deprecated { - _ = cmd.Flags().MarkDeprecated(p.Flag, "this flag is deprecated") - } + bindParamFlag(cmd, vals, s.Params[i], s.RequestBody != nil) } if s.RequestBody != nil { fileHelp := "path to JSON body file, or '-' for stdin" @@ -405,43 +195,70 @@ func buildCmd(s CommandSpec) *cobra.Command { return cmd } -type dryRunRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - Body any `json:"body"` - Auth dryRunAuth `json:"auth"` - Output CatalogOutput `json:"output"` -} - -type dryRunAuth struct { - Required bool `json:"required"` - Public bool `json:"public"` - Scopes []string `json:"scopes,omitempty"` -} - -func writeDryRun(cmd *cobra.Command, s CommandSpec, hostname, path string, body any, opts ClientOptions) error { - req, bodyBytes, _, err := resolveRequest(cmd.Context(), hostname, s.Method, path, body, opts) - if err != nil { - return err +func bindParamFlag(cmd *cobra.Command, vals map[string]any, p ParamSpec, hasRequestBody bool) { + if p.In == InPath { + v := new(string) + vals[p.Name] = v + cmd.Flags().StringVar(v, p.Flag, p.Default, p.Help) + addSafeInputFlags(cmd, p) + if p.Default == "" && !isSensitiveStringParam(p) { + _ = cmd.MarkFlagRequired(p.Flag) + } + if p.Deprecated { + _ = cmd.Flags().MarkDeprecated(p.Flag, "this flag is deprecated") + } + return } - out := dryRunRequest{ - Method: req.Method, - URL: req.URL.String(), - Headers: redactedDryRunHeaders(req.Header), - Body: redactedDryRunBody(req.Header.Get("Content-Type"), bodyBytes), - Auth: dryRunAuthForSpec(s), - Output: CatalogOutput{ - ListPath: s.Output.ListPath, - DefaultColumns: append([]string(nil), s.Output.DefaultColumns...), - ResponseMediaType: s.Output.ResponseMediaType, - Pagination: catalogPagination(s.Output.Pagination), - Streaming: catalogStreaming(s.Output.Streaming), - }, + switch p.GoType { + case "int64": + v := new(int64) + vals[p.Name] = v + var def int64 + if p.Default != "" { + def, _ = strconv.ParseInt(p.Default, 10, 64) + } + cmd.Flags().Int64Var(v, p.Flag, def, p.Help) + case "float64": + v := new(float64) + vals[p.Name] = v + var def float64 + if p.Default != "" { + def, _ = strconv.ParseFloat(p.Default, 64) + } + cmd.Flags().Float64Var(v, p.Flag, def, p.Help) + case "bool": + v := new(bool) + vals[p.Name] = v + def := p.Default == "true" + cmd.Flags().BoolVar(v, p.Flag, def, p.Help) + case "[]int64": + v := new([]int64) + vals[p.Name] = v + cmd.Flags().Int64SliceVar(v, p.Flag, nil, p.Help) + case "[]float64": + v := new([]float64) + vals[p.Name] = v + cmd.Flags().Float64SliceVar(v, p.Flag, nil, p.Help) + case "[]bool": + v := new([]bool) + vals[p.Name] = v + cmd.Flags().BoolSliceVar(v, p.Flag, nil, p.Help) + case "[]string": + v := new([]string) + vals[p.Name] = v + cmd.Flags().StringSliceVar(v, p.Flag, nil, p.Help) + default: + v := new(string) + vals[p.Name] = v + cmd.Flags().StringVar(v, p.Flag, p.Default, p.Help) + addSafeInputFlags(cmd, p) + } + if p.Required && p.Default == "" && (p.In != InVariable || !hasRequestBody) && !isSensitiveStringParam(p) { + _ = cmd.MarkFlagRequired(p.Flag) + } + if p.Deprecated { + _ = cmd.Flags().MarkDeprecated(p.Flag, "this flag is deprecated") } - enc := json.NewEncoder(cmd.OutOrStdout()) - enc.SetIndent("", " ") - return enc.Encode(out) } func redactedDryRunHeaders(headers map[string][]string) map[string]string { @@ -466,8 +283,8 @@ func redactedDryRunBody(contentType string, body []byte) any { return string(redacted) } -func dryRunAuthForSpec(s CommandSpec) dryRunAuth { - out := dryRunAuth{Required: true} +func dryRunAuthForSpec(s CommandSpec) DryRunAuth { + out := DryRunAuth{Required: true} if s.Security != nil { out.Required = !s.Security.Public out.Public = s.Security.Public @@ -726,6 +543,17 @@ func flagChangedOrDefault(cmd *cobra.Command, p ParamSpec) bool { return cmd.Flags().Changed(p.Flag+"-env") || cmd.Flags().Changed(p.Flag+"-file") || cmd.Flags().Changed(p.Flag+"-stdin") } +func operationChangedFlags(cmd *cobra.Command, params []ParamSpec) map[string]bool { + changed := make(map[string]bool, len(params)*2) + for _, p := range params { + if flagChangedOrDefault(cmd, p) { + changed[p.Name] = true + changed[p.Flag] = true + } + } + return changed +} + func isSensitiveStringParam(p ParamSpec) bool { if p.GoType != "string" { return false @@ -751,37 +579,3 @@ func sensitiveNameKey(s string) string { } return b.String() } - -func flagStringValue(v any) string { - switch tv := v.(type) { - case *string: - return *tv - case *int64: - return strconv.FormatInt(*tv, 10) - case *float64: - return strconv.FormatFloat(*tv, 'f', -1, 64) - case *bool: - return strconv.FormatBool(*tv) - case *[]int64: - if len(*tv) > 0 { - return strconv.FormatInt((*tv)[0], 10) - } - return "" - case *[]float64: - if len(*tv) > 0 { - return strconv.FormatFloat((*tv)[0], 'f', -1, 64) - } - return "" - case *[]bool: - if len(*tv) > 0 { - return strconv.FormatBool((*tv)[0]) - } - return "" - case *[]string: - if len(*tv) > 0 { - return (*tv)[0] - } - return "" - } - return "" -} diff --git a/pkg/runtime/catalog.go b/pkg/runtime/catalog.go index 13b1873..cf68e7b 100644 --- a/pkg/runtime/catalog.go +++ b/pkg/runtime/catalog.go @@ -10,12 +10,13 @@ import ( "github.com/spf13/cobra" ) -const CatalogSchemaVersion = 10 +const CatalogSchemaVersion = 11 const DefaultSearchLimit = 20 const catalogCommandAnnotation = "lathe.catalog.command" const catalogCapabilitiesAnnotation = "lathe.catalog.capabilities" const CapabilitySkillBundle = "skill.bundle" +const CapabilityWorkflowDSL = "workflow.dsl" type CatalogOptions struct { CLIName string @@ -48,6 +49,7 @@ type CatalogOutputFormats struct { } type CatalogCommand struct { + Kind string `json:"kind"` Path []string `json:"path"` Service string `json:"service"` Group string `json:"group"` @@ -60,6 +62,7 @@ type CatalogCommand struct { Examples []CommandExample `json:"examples,omitempty"` OperationID string `json:"operation_id,omitempty"` HTTP CatalogHTTP `json:"http"` + Workflow *CatalogWorkflow `json:"workflow,omitempty"` Auth CatalogAuth `json:"auth"` Body *CatalogBody `json:"body,omitempty"` Flags []CatalogFlag `json:"flags"` @@ -71,6 +74,18 @@ type CatalogCommand struct { KnownErrors []KnownError `json:"known_errors,omitempty"` } +type CatalogWorkflow struct { + DSL string `json:"dsl"` + OutputFrom string `json:"output_from,omitempty"` + Steps []CatalogWorkflowStep `json:"steps"` +} + +type CatalogWorkflowStep struct { + ID string `json:"id"` + OperationID string `json:"operation_id,omitempty"` + HTTP CatalogHTTP `json:"http"` +} + type CatalogHTTP struct { Method string `json:"method"` PathTemplate string `json:"path_template"` @@ -140,6 +155,18 @@ func AttachCatalogCommand(cmd *cobra.Command, service string, spec CommandSpec) cmd.Annotations[catalogCommandAnnotation] = string(raw) } +func AttachCatalogWorkflowCommand(cmd *cobra.Command, spec WorkflowSpec) { + entry := catalogWorkflowCommand(spec, nil) + raw, err := json.Marshal(entry) + if err != nil { + panic(err) + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[catalogCommandAnnotation] = string(raw) +} + func AttachCapability(root *cobra.Command, capability string) { if root == nil || capability == "" { return @@ -339,6 +366,7 @@ func catalogCommand(service string, spec CommandSpec, path []string) CatalogComm examples = []CommandExample{{Command: spec.Example}} } cmd := CatalogCommand{ + Kind: "operation", Path: append([]string(nil), path...), Service: service, Group: spec.Group, @@ -378,6 +406,76 @@ func catalogCommand(service string, spec CommandSpec, path []string) CatalogComm return cmd } +func catalogWorkflowCommand(spec WorkflowSpec, path []string) CatalogCommand { + flags := make([]CatalogFlag, 0, len(spec.Params)) + for _, p := range spec.Params { + var inputModes []string + if isSensitiveStringParam(p) { + inputModes = []string{"flag", "env", "file", "stdin"} + } + flags = append(flags, CatalogFlag{ + Name: p.Name, + Flag: p.Flag, + Location: p.In, + Type: p.GoType, + Required: p.Required, + Default: p.Default, + Enum: append([]string(nil), p.Enum...), + Format: p.Format, + InputModes: inputModes, + Deprecated: p.Deprecated, + Help: p.Help, + }) + } + steps := make([]CatalogWorkflowStep, 0, len(spec.Steps)) + auth := CatalogAuth{Required: false} + for _, step := range spec.Steps { + steps = append(steps, CatalogWorkflowStep{ + ID: step.ID, + OperationID: step.Operation.OperationID, + HTTP: CatalogHTTP{ + Method: step.Operation.Method, + PathTemplate: step.Operation.PathTpl, + DefaultHostname: step.Operation.DefaultHostname, + }, + }) + stepAuth := catalogAuth(step.Operation.Security) + if stepAuth.Required { + auth.Required = true + } + auth.Scopes = append(auth.Scopes, stepAuth.Scopes...) + } + auth.Scopes = normalizeCapabilities(auth.Scopes) + return CatalogCommand{ + Kind: "workflow", + Path: append([]string(nil), path...), + Service: "workflow", + Group: "workflow", + Use: spec.Use, + Aliases: append([]string(nil), spec.Aliases...), + Summary: spec.Short, + Description: spec.Long, + Example: spec.Example, + HTTP: CatalogHTTP{}, + Workflow: &CatalogWorkflow{ + DSL: "lathe.workflow.v1", + OutputFrom: spec.OutputFrom, + Steps: steps, + }, + Auth: auth, + Flags: flags, + Output: CatalogOutput{ + ListPath: spec.Output.ListPath, + DefaultColumns: append([]string(nil), spec.Output.DefaultColumns...), + ResponseMediaType: spec.Output.ResponseMediaType, + Pagination: catalogPagination(spec.Output.Pagination), + Streaming: catalogStreaming(spec.Output.Streaming), + }, + Hidden: spec.Hidden, + Deprecated: spec.Deprecated, + } +} + func cloneShortcuts(shortcuts []CommandShortcut) []CommandShortcut { out := make([]CommandShortcut, 0, len(shortcuts)) for _, shortcut := range shortcuts { diff --git a/pkg/runtime/catalog_test.go b/pkg/runtime/catalog_test.go index 234d65b..07b431e 100644 --- a/pkg/runtime/catalog_test.go +++ b/pkg/runtime/catalog_test.go @@ -59,6 +59,9 @@ func TestBuildCatalog_UsesAttachedSpec(t *testing.T) { } cmd := catalog.Commands[0] + if cmd.Kind != "operation" { + t.Fatalf("kind = %q", cmd.Kind) + } if !reflect.DeepEqual(cmd.Path, []string{"demo", "users", "get-user"}) { t.Fatalf("path = %#v", cmd.Path) } @@ -136,6 +139,66 @@ func TestBuildCatalog_UsesAttachedSpec(t *testing.T) { } } +func TestBuildCatalog_WorkflowCommand(t *testing.T) { + root := newRootWithModuleGroup() + if err := BuildWorkflows(root, []WorkflowSpec{{ + Use: "doctor", + Short: "Check API health", + Params: []ParamSpec{ + {Name: "tenant", Flag: "tenant", In: InInput, GoType: "string", Required: true}, + }, + Steps: []WorkflowStepSpec{ + { + ID: "health", + Operation: CommandSpec{ + OperationID: "getHealth", + Method: "GET", + PathTpl: "/health", + Security: &SecurityHint{Public: true}, + }, + }, + { + ID: "tenant", + Operation: CommandSpec{ + OperationID: "checkTenant", + Method: "GET", + PathTpl: "/tenants/{tenant}", + Params: []ParamSpec{ + {Name: "tenant", Flag: "tenant", In: InPath, GoType: "string", Required: true}, + }, + }, + }, + }, + OutputFrom: "${steps.tenant}", + }}); err != nil { + t.Fatalf("BuildWorkflows: %v", err) + } + + catalog := BuildCatalog(root, CatalogOptions{CLIName: "myctl"}) + if len(catalog.Commands) != 1 { + t.Fatalf("commands = %d", len(catalog.Commands)) + } + cmd := catalog.Commands[0] + if cmd.Kind != "workflow" { + t.Fatalf("kind = %q", cmd.Kind) + } + if !reflect.DeepEqual(cmd.Path, []string{"doctor"}) { + t.Fatalf("path = %#v", cmd.Path) + } + if cmd.Workflow == nil || cmd.Workflow.DSL != "lathe.workflow.v1" || cmd.Workflow.OutputFrom != "${steps.tenant}" { + t.Fatalf("workflow = %+v", cmd.Workflow) + } + if len(cmd.Workflow.Steps) != 2 || cmd.Workflow.Steps[1].OperationID != "checkTenant" { + t.Fatalf("steps = %+v", cmd.Workflow.Steps) + } + if len(cmd.Flags) != 1 || cmd.Flags[0].Location != InInput { + t.Fatalf("flags = %+v", cmd.Flags) + } + if !cmd.Auth.Required { + t.Fatalf("auth = %+v", cmd.Auth) + } +} + func TestBuildCatalog_ProjectsLegacyExample(t *testing.T) { root := newRootWithModuleGroup() mustBuild(t, root, "demo", []CommandSpec{{ diff --git a/pkg/runtime/operation.go b/pkg/runtime/operation.go new file mode 100644 index 0000000..6ac42cf --- /dev/null +++ b/pkg/runtime/operation.go @@ -0,0 +1,412 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "strconv" + "strings" +) + +type OperationInput struct { + Values map[string]any + Changed map[string]bool + FileBody []byte + HasFile bool + BodySets []string + BodyStringSets []string +} + +type OperationOptions struct { + Hostname string + Client ClientOptions + DryRun bool + PaginateAll bool + MaxPages int + Wait bool +} + +type OperationResult struct { + Data []byte + DryRun *DryRunRequest +} + +type DryRunRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body any `json:"body"` + Auth DryRunAuth `json:"auth"` + Output CatalogOutput `json:"output"` +} + +type DryRunAuth struct { + Required bool `json:"required"` + Public bool `json:"public"` + Scopes []string `json:"scopes,omitempty"` +} + +func InvokeOperation(ctx context.Context, s CommandSpec, input OperationInput, opts OperationOptions) (OperationResult, error) { + path, body, clientOpts, err := resolveOperationRequest(s, input, opts.Client) + if err != nil { + return OperationResult{}, err + } + if opts.DryRun { + out, err := buildDryRunRequest(ctx, s, opts.Hostname, path, body, clientOpts) + if err != nil { + return OperationResult{}, err + } + return OperationResult{DryRun: &out}, nil + } + + var data []byte + if opts.PaginateAll && s.Output.Pagination != nil { + maxPages := opts.MaxPages + if maxPages == 0 { + maxPages = DefaultMaxPages + } + data, err = PaginateAll(ctx, opts.Hostname, s.Method, path, body, clientOpts, *s.Output.Pagination, s.Output.ListPath, maxPages) + } else if opts.Wait { + var r *RawResult + r, err = DoRawFull(ctx, opts.Hostname, s.Method, path, body, clientOpts) + if err == nil && r.StatusCode == 202 { + if loc := r.Header.Get("Location"); loc != "" { + data, err = PollUntilDone(ctx, opts.Hostname, loc, clientOpts, DefaultPollTimeout) + } else { + data = r.Body + } + } else if err == nil { + data = r.Body + } + } else { + data, err = DoRaw(ctx, opts.Hostname, s.Method, path, body, clientOpts) + } + if err != nil { + return OperationResult{}, err + } + return OperationResult{Data: data}, nil +} + +func resolveOperationRequest(s CommandSpec, input OperationInput, clientOpts ClientOptions) (string, any, ClientOptions, error) { + if err := validateRequiredOperationParams(s, input); err != nil { + return "", nil, ClientOptions{}, err + } + if err := validateOperationEnums(s, input); err != nil { + return "", nil, ClientOptions{}, err + } + + path := s.PathTpl + q := url.Values{} + hdrs := map[string]string{} + form := url.Values{} + vars := map[string]any{} + for _, p := range s.Params { + switch p.In { + case InPath: + v, ok, err := operationValue(input, p) + if err != nil { + return "", nil, ClientOptions{}, err + } + if !ok { + continue + } + path = strings.Replace(path, "{"+p.Name+"}", url.PathEscape(operationStringValue(v)), 1) + continue + case InHeader: + if !operationChanged(input, p) { + continue + } + v, _, err := operationValue(input, p) + if err != nil { + return "", nil, ClientOptions{}, err + } + hdrs[p.Name] = operationStringValue(v) + continue + case InVariable: + if !operationChanged(input, p) { + continue + } + v, _, err := operationValue(input, p) + if err != nil { + return "", nil, ClientOptions{}, err + } + vars[p.Name] = v + continue + case InFormData: + if !operationChanged(input, p) { + continue + } + v, _, err := operationValue(input, p) + if err != nil { + return "", nil, ClientOptions{}, err + } + form.Set(p.Name, operationStringValue(v)) + continue + } + if !operationChanged(input, p) { + continue + } + v, _, err := operationValue(input, p) + if err != nil { + return "", nil, ClientOptions{}, err + } + switch tv := v.(type) { + case int64: + q.Set(p.Name, strconv.FormatInt(tv, 10)) + case bool: + q.Set(p.Name, strconv.FormatBool(tv)) + case []string: + for _, vv := range tv { + q.Add(p.Name, vv) + } + case string: + q.Set(p.Name, tv) + } + } + if enc := q.Encode(); enc != "" { + path = path + "?" + enc + } + + body, err := resolveOperationBody(s, input, form, vars) + if err != nil { + return "", nil, ClientOptions{}, err + } + if err := validateRequiredVariableParams(s, body); err != nil { + return "", nil, ClientOptions{}, err + } + if body != nil && s.RequestBody != nil && s.RequestBody.MediaType != "" { + hdrs["Content-Type"] = s.RequestBody.MediaType + } + + clientOpts.Headers = hdrs + if s.Output.ResponseMediaType != "" { + clientOpts.Accept = s.Output.ResponseMediaType + } + return path, body, clientOpts, nil +} + +func resolveOperationBody(s CommandSpec, input OperationInput, form url.Values, vars map[string]any) (any, error) { + if len(form) > 0 { + return form, nil + } + if s.RequestBody == nil { + return nil, nil + } + if s.RequestBody.Template != "" { + return buildEnvelopeBody(s.RequestBody.Template, s.RequestBody.MergePath, vars, input.BodySets, input.BodyStringSets, input.FileBody, input.HasFile) + } + switch { + case len(input.BodySets) > 0 || len(input.BodyStringSets) > 0: + if !supportsJSONBodyBuilder(s.RequestBody.MediaType) { + return nil, fmt.Errorf("request body media type %s requires --file; --set and --set-str only support JSON request bodies", s.RequestBody.MediaType) + } + return buildBodyFromSet(input.BodySets, input.BodyStringSets) + case input.HasFile: + return input.FileBody, nil + case s.RequestBody.Required: + if !supportsJSONBodyBuilder(s.RequestBody.MediaType) { + return nil, fmt.Errorf("request body media type %s requires --file", s.RequestBody.MediaType) + } + return nil, fmt.Errorf("request body required: pass --file, --set, or --set-str") + default: + return nil, nil + } +} + +func buildDryRunRequest(ctx context.Context, s CommandSpec, hostname, path string, body any, opts ClientOptions) (DryRunRequest, error) { + req, bodyBytes, _, err := resolveRequest(ctx, hostname, s.Method, path, body, opts) + if err != nil { + return DryRunRequest{}, err + } + return DryRunRequest{ + Method: req.Method, + URL: req.URL.String(), + Headers: redactedDryRunHeaders(req.Header), + Body: redactedDryRunBody(req.Header.Get("Content-Type"), bodyBytes), + Auth: dryRunAuthForSpec(s), + Output: CatalogOutput{ + ListPath: s.Output.ListPath, + DefaultColumns: append([]string(nil), s.Output.DefaultColumns...), + ResponseMediaType: s.Output.ResponseMediaType, + Pagination: catalogPagination(s.Output.Pagination), + Streaming: catalogStreaming(s.Output.Streaming), + }, + }, nil +} + +func validateRequiredOperationParams(s CommandSpec, input OperationInput) error { + for _, p := range s.Params { + if !p.Required || p.Default != "" { + continue + } + if p.In == InVariable && s.RequestBody != nil { + continue + } + if !operationChanged(input, p) { + return fmt.Errorf("required param %q missing", p.Name) + } + } + return nil +} + +func validateOperationEnums(s CommandSpec, input OperationInput) error { + for _, p := range s.Params { + if len(p.Enum) == 0 || !operationChanged(input, p) { + continue + } + v, _, err := operationValue(input, p) + if err != nil { + return err + } + raw := operationStringValue(v) + valid := false + for _, e := range p.Enum { + if raw == e { + valid = true + break + } + } + if !valid { + return fmt.Errorf("invalid value %q for --%s: must be one of %s", raw, p.Flag, strings.Join(p.Enum, ", ")) + } + } + return nil +} + +func operationChanged(input OperationInput, p ParamSpec) bool { + if p.Default != "" { + return true + } + if input.Changed != nil { + return input.Changed[p.Name] || input.Changed[p.Flag] + } + if _, ok := input.Values[p.Name]; ok { + return true + } + if _, ok := input.Values[p.Flag]; ok { + return true + } + return false +} + +func operationValue(input OperationInput, p ParamSpec) (any, bool, error) { + v, ok := input.Values[p.Name] + if !ok { + v, ok = input.Values[p.Flag] + } + if !ok { + if p.Default == "" { + return nil, false, nil + } + v = p.Default + } + out, err := coerceOperationValue(v, p) + if err != nil { + return nil, false, err + } + return out, true, nil +} + +func coerceOperationValue(v any, p ParamSpec) (any, error) { + switch tv := v.(type) { + case *string: + return *tv, nil + case *int64: + return *tv, nil + case *float64: + return *tv, nil + case *bool: + return *tv, nil + case *[]int64: + return append([]int64(nil), (*tv)...), nil + case *[]float64: + return append([]float64(nil), (*tv)...), nil + case *[]bool: + return append([]bool(nil), (*tv)...), nil + case *[]string: + return append([]string(nil), (*tv)...), nil + case string: + return parseStringOperationValue(tv, p) + case int: + return int64(tv), nil + case int64: + return tv, nil + case float64: + return tv, nil + case bool: + return tv, nil + case []int64: + return append([]int64(nil), tv...), nil + case []float64: + return append([]float64(nil), tv...), nil + case []bool: + return append([]bool(nil), tv...), nil + case []string: + return append([]string(nil), tv...), nil + default: + return tv, nil + } +} + +func parseStringOperationValue(raw string, p ParamSpec) (any, error) { + switch p.GoType { + case "int64": + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil, err + } + return v, nil + case "float64": + v, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil, err + } + return v, nil + case "bool": + v, err := strconv.ParseBool(raw) + if err != nil { + return nil, err + } + return v, nil + default: + return raw, nil + } +} + +func operationStringValue(v any) string { + switch tv := v.(type) { + case string: + return tv + case int64: + return strconv.FormatInt(tv, 10) + case float64: + return strconv.FormatFloat(tv, 'f', -1, 64) + case bool: + return strconv.FormatBool(tv) + case []int64: + if len(tv) > 0 { + return strconv.FormatInt(tv[0], 10) + } + case []float64: + if len(tv) > 0 { + return strconv.FormatFloat(tv[0], 'f', -1, 64) + } + case []bool: + if len(tv) > 0 { + return strconv.FormatBool(tv[0]) + } + case []string: + if len(tv) > 0 { + return tv[0] + } + } + return "" +} + +func writeDryRun(out DryRunRequest, w io.Writer) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(out) +} diff --git a/pkg/runtime/spec.go b/pkg/runtime/spec.go index 5c2d1bb..765e518 100644 --- a/pkg/runtime/spec.go +++ b/pkg/runtime/spec.go @@ -65,6 +65,7 @@ const ( InHeader = "header" InFormData = "formData" InVariable = "variable" + InInput = "input" ) type RequestBody struct { @@ -112,3 +113,30 @@ type KnownError struct { Status int `json:"status,omitempty"` Cause string `json:"cause,omitempty"` } + +type WorkflowSpec struct { + Use string + Aliases []string + Short string + Long string + Example string + Hidden bool + Deprecated bool + Params []ParamSpec + Steps []WorkflowStepSpec + OutputFrom string + Output OutputHints +} + +type WorkflowStepSpec struct { + ID string + Operation CommandSpec + Params map[string]string + BodySets []WorkflowValue + BodyStringSets []WorkflowValue +} + +type WorkflowValue struct { + Name string + Value string +} diff --git a/pkg/runtime/workflow.go b/pkg/runtime/workflow.go new file mode 100644 index 0000000..bfeff32 --- /dev/null +++ b/pkg/runtime/workflow.go @@ -0,0 +1,317 @@ +package runtime + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +type WorkflowResult struct { + Status string `json:"status"` + Steps []WorkflowStepResult `json:"steps"` +} + +type WorkflowStepResult struct { + ID string `json:"id"` + Status string `json:"status"` +} + +type WorkflowError struct { + StepID string + Err error + Result WorkflowResult +} + +func (e *WorkflowError) Error() string { + return fmt.Sprintf("workflow step %q failed: %v", e.StepID, e.Err) +} + +func (e *WorkflowError) Unwrap() error { + return e.Err +} + +func BuildWorkflows(root *cobra.Command, specs []WorkflowSpec) error { + for _, spec := range specs { + if findChildCommand(root, spec.Use) != nil { + return fmt.Errorf("workflow command %q conflicts with existing root command", spec.Use) + } + for _, alias := range spec.Aliases { + if findChildCommand(root, alias) != nil { + return fmt.Errorf("workflow command %q alias %q conflicts with existing root command", spec.Use, alias) + } + } + } + if len(specs) > 0 { + AttachCapability(root, CapabilityWorkflowDSL) + } + for _, spec := range specs { + cmd := buildWorkflowCmd(spec) + AttachCatalogWorkflowCommand(cmd, spec) + root.AddCommand(cmd) + } + return nil +} + +func buildWorkflowCmd(spec WorkflowSpec) *cobra.Command { + vals := make(map[string]any, len(spec.Params)) + cmd := &cobra.Command{ + Use: spec.Use, + Aliases: spec.Aliases, + Short: spec.Short, + Long: spec.Long, + Example: spec.Example, + Hidden: spec.Hidden, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := resolveSafeInputFlags(cmd, spec.Params, vals); err != nil { + return err + } + if err := validateRequiredSafeParams(cmd, spec.Params, false); err != nil { + return err + } + if err := validateOperationEnums(CommandSpec{Params: spec.Params}, OperationInput{ + Values: vals, + Changed: operationChangedFlags(cmd, spec.Params), + }); err != nil { + return err + } + result, data, err := executeWorkflow(cmd, spec, vals) + if err != nil { + return err + } + if data == nil { + var marshalErr error + data, marshalErr = json.Marshal(result) + if marshalErr != nil { + return marshalErr + } + } + format, _ := cmd.Root().PersistentFlags().GetString("output") + return FormatOutput(data, format, cmd.OutOrStdout(), spec.Output) + }, + } + for _, p := range spec.Params { + bindParamFlag(cmd, vals, p, false) + } + if spec.Deprecated { + cmd.Deprecated = "this command is deprecated" + } + return cmd +} + +func executeWorkflow(cmd *cobra.Command, spec WorkflowSpec, vals map[string]any) (WorkflowResult, []byte, error) { + state := workflowState{ + inputs: workflowInputValues(cmd, spec, vals), + steps: map[string]any{}, + } + result := WorkflowResult{Status: "ok", Steps: make([]WorkflowStepResult, 0, len(spec.Steps))} + for _, step := range spec.Steps { + stepResult := WorkflowStepResult{ID: step.ID, Status: "ok"} + var hostname string + var clientOpts ClientOptions + var err error + if step.Operation.Security != nil && step.Operation.Security.Public { + hostname, clientOpts, err = tryLoadHostOptionsMaybeRefresh(cmd, step.Operation.DefaultHostname, true) + } else { + hostname, clientOpts, err = loadHostOptionsMaybeRefresh(cmd, step.Operation.DefaultHostname, true) + } + if err != nil { + stepResult.Status = "failed" + result.Status = "failed" + result.Steps = append(result.Steps, stepResult) + return result, nil, &WorkflowError{StepID: step.ID, Err: err, Result: result} + } + if v, err := cmd.Root().PersistentFlags().GetBool("debug"); err == nil && v { + clientOpts.Debug = true + } + clientOpts.UserAgent = cmd.Root().Use + input, err := workflowOperationInput(step, state) + if err != nil { + stepResult.Status = "failed" + result.Status = "failed" + result.Steps = append(result.Steps, stepResult) + return result, nil, &WorkflowError{StepID: step.ID, Err: err, Result: result} + } + opResult, err := InvokeOperation(cmd.Context(), step.Operation, input, OperationOptions{ + Hostname: hostname, + Client: clientOpts, + }) + if err != nil { + stepResult.Status = "failed" + result.Status = "failed" + result.Steps = append(result.Steps, stepResult) + return result, nil, &WorkflowError{StepID: step.ID, Err: err, Result: result} + } + state.steps[step.ID] = workflowStepValue(opResult.Data) + result.Steps = append(result.Steps, stepResult) + } + if strings.TrimSpace(spec.OutputFrom) == "" { + return result, nil, nil + } + value, err := evalWorkflowValue(spec.OutputFrom, state) + if err != nil { + return result, nil, err + } + data, err := json.Marshal(value) + if err != nil { + return result, nil, err + } + return result, data, nil +} + +type workflowState struct { + inputs map[string]any + steps map[string]any +} + +func workflowInputValues(cmd *cobra.Command, spec WorkflowSpec, vals map[string]any) map[string]any { + input := OperationInput{Values: vals, Changed: operationChangedFlags(cmd, spec.Params)} + out := make(map[string]any, len(spec.Params)) + for _, p := range spec.Params { + if !operationChanged(input, p) { + continue + } + v, ok, err := operationValue(input, p) + if err != nil || !ok { + continue + } + out[p.Name] = v + out[p.Flag] = v + } + return out +} + +func workflowOperationInput(step WorkflowStepSpec, state workflowState) (OperationInput, error) { + values := make(map[string]any, len(step.Params)) + for key, expr := range step.Params { + value, err := evalWorkflowValue(expr, state) + if err != nil { + return OperationInput{}, fmt.Errorf("step %s param %s: %w", step.ID, key, err) + } + values[key] = value + } + sets, err := evalWorkflowAssignments(step.BodySets, state) + if err != nil { + return OperationInput{}, fmt.Errorf("step %s body set: %w", step.ID, err) + } + stringSets, err := evalWorkflowAssignments(step.BodyStringSets, state) + if err != nil { + return OperationInput{}, fmt.Errorf("step %s body set-str: %w", step.ID, err) + } + return OperationInput{ + Values: values, + BodySets: sets, + BodyStringSets: stringSets, + }, nil +} + +func evalWorkflowAssignments(values []WorkflowValue, state workflowState) ([]string, error) { + out := make([]string, 0, len(values)) + for _, value := range values { + evaluated, err := evalWorkflowString(value.Value, state) + if err != nil { + return nil, err + } + out = append(out, value.Name+"="+evaluated) + } + return out, nil +} + +func workflowStepValue(data []byte) any { + if len(bytes.TrimSpace(data)) == 0 { + return nil + } + var value any + if err := json.Unmarshal(data, &value); err == nil { + return value + } + return string(data) +} + +func evalWorkflowString(expr string, state workflowState) (string, error) { + if !strings.Contains(expr, "${") { + return expr, nil + } + var out strings.Builder + rest := expr + for { + start := strings.Index(rest, "${") + if start < 0 { + out.WriteString(rest) + return out.String(), nil + } + out.WriteString(rest[:start]) + after := rest[start+2:] + end := strings.Index(after, "}") + if end < 0 { + return "", fmt.Errorf("unterminated reference in %q", expr) + } + ref := strings.TrimSpace(after[:end]) + value, err := workflowRefValue(ref, state) + if err != nil { + return "", err + } + out.WriteString(workflowString(value)) + rest = after[end+1:] + } +} + +func evalWorkflowValue(expr string, state workflowState) (any, error) { + trimmed := strings.TrimSpace(expr) + if strings.HasPrefix(trimmed, "${") && strings.HasSuffix(trimmed, "}") && strings.Count(trimmed, "${") == 1 { + return workflowRefValue(strings.TrimSpace(trimmed[2:len(trimmed)-1]), state) + } + return evalWorkflowString(expr, state) +} + +func workflowRefValue(ref string, state workflowState) (any, error) { + if name, ok := strings.CutPrefix(ref, "input."); ok { + value, exists := state.inputs[name] + if !exists { + return nil, fmt.Errorf("unknown input %q", name) + } + return value, nil + } + if rest, ok := strings.CutPrefix(ref, "steps."); ok { + id, path, _ := strings.Cut(rest, ".") + step, exists := state.steps[id] + if !exists { + return nil, fmt.Errorf("unknown step %q", id) + } + if path == "" { + return step, nil + } + value, exists := getNestedPath(step, path) + if !exists { + return nil, fmt.Errorf("step %q has no output path %q", id, path) + } + return value, nil + } + return nil, fmt.Errorf("unknown reference %q", ref) +} + +func workflowString(value any) string { + switch tv := value.(type) { + case nil: + return "" + case string: + return tv + case []byte: + return string(tv) + case json.Number: + return tv.String() + case bool: + return fmt.Sprint(tv) + case float64: + return strconv.FormatFloat(tv, 'f', -1, 64) + default: + data, err := json.Marshal(tv) + if err == nil { + return string(data) + } + return fmt.Sprint(tv) + } +} diff --git a/pkg/runtime/workflow_test.go b/pkg/runtime/workflow_test.go new file mode 100644 index 0000000..ada508a --- /dev/null +++ b/pkg/runtime/workflow_test.go @@ -0,0 +1,222 @@ +package runtime + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestBuildWorkflows_ExecutesStepsWithReferences(t *testing.T) { + bindTestManifest(t, "myctl", "MYCTL_HOST") + t.Setenv("MYCTL_CONFIG_DIR", t.TempDir()) + + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.String()) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/health": + _, _ = w.Write([]byte(`{"tenant":"tenant 1"}`)) + case "/tenants/tenant 1/check": + _, _ = w.Write([]byte(`{"ok":true}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + root := newWorkflowRoot(&bytes.Buffer{}) + if err := BuildWorkflows(root, []WorkflowSpec{{ + Use: "doctor", + Short: "Check API health", + Steps: []WorkflowStepSpec{ + { + ID: "health", + Operation: CommandSpec{ + Group: "System", + Use: "get-health", + Method: "GET", + PathTpl: "/health", + Security: &SecurityHint{Public: true}, + }, + }, + { + ID: "tenant", + Operation: CommandSpec{ + Group: "Tenants", + Use: "check-tenant", + Method: "GET", + PathTpl: "/tenants/{tenant}/check", + Params: []ParamSpec{ + {Name: "tenant", Flag: "tenant", In: InPath, GoType: "string", Required: true}, + }, + Security: &SecurityHint{Public: true}, + }, + Params: map[string]string{"tenant": "${steps.health.tenant}"}, + }, + }, + OutputFrom: "${steps.tenant}", + }}); err != nil { + t.Fatalf("BuildWorkflows: %v", err) + } + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"--hostname", srv.URL, "doctor"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if strings.TrimSpace(stdout.String()) != `{"ok":true}` { + t.Fatalf("stdout = %q", stdout.String()) + } + want := []string{"GET /health", "GET /tenants/tenant%201/check"} + if strings.Join(requests, "|") != strings.Join(want, "|") { + t.Fatalf("requests = %#v", requests) + } +} + +func TestBuildWorkflows_StopsOnFailedStep(t *testing.T) { + bindTestManifest(t, "myctl", "MYCTL_HOST") + t.Setenv("MYCTL_CONFIG_DIR", t.TempDir()) + + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if r.URL.Path == "/first" { + _, _ = w.Write([]byte(`{"ok":true}`)) + return + } + http.Error(w, `{"error":"down"}`, http.StatusBadRequest) + })) + defer srv.Close() + + root := newWorkflowRoot(io.Discard) + if err := BuildWorkflows(root, []WorkflowSpec{{ + Use: "doctor", + Steps: []WorkflowStepSpec{ + {ID: "first", Operation: publicGetSpec("first", "/first")}, + {ID: "second", Operation: publicGetSpec("second", "/second")}, + {ID: "third", Operation: publicGetSpec("third", "/third")}, + }, + }}); err != nil { + t.Fatalf("BuildWorkflows: %v", err) + } + root.SetArgs([]string{"--hostname", srv.URL, "doctor"}) + err := root.Execute() + if err == nil { + t.Fatal("expected workflow error") + } + var workflowErr *WorkflowError + if !errors.As(err, &workflowErr) { + t.Fatalf("error = %T %v, want WorkflowError", err, err) + } + if workflowErr.StepID != "second" { + t.Fatalf("failed step = %q", workflowErr.StepID) + } + for _, path := range paths { + if path == "/third" { + t.Fatalf("third step ran: paths = %#v", paths) + } + } + if len(paths) < 2 { + t.Fatalf("paths = %#v, want first and second attempts", paths) + } +} + +func TestBuildWorkflows_RejectsInvalidInputEnum(t *testing.T) { + root := newWorkflowRoot(io.Discard) + if err := BuildWorkflows(root, []WorkflowSpec{{ + Use: "doctor", + Params: []ParamSpec{{ + Name: "mode", + Flag: "mode", + In: InInput, + GoType: "string", + Enum: []string{"quick", "full"}, + }}, + Steps: []WorkflowStepSpec{{ID: "health", Operation: publicGetSpec("health", "/health")}}, + }}); err != nil { + t.Fatalf("BuildWorkflows: %v", err) + } + root.SetArgs([]string{"--hostname", "http://127.0.0.1:1", "doctor", "--mode", "broken"}) + + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), `invalid value "broken" for --mode`) { + t.Fatalf("error = %v", err) + } +} + +func TestBuildWorkflows_PreservesTypedParamReference(t *testing.T) { + bindTestManifest(t, "myctl", "MYCTL_HOST") + t.Setenv("MYCTL_CONFIG_DIR", t.TempDir()) + + var tags []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tags = r.URL.Query()["tag"] + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + root := newWorkflowRoot(io.Discard) + if err := BuildWorkflows(root, []WorkflowSpec{{ + Use: "doctor", + Params: []ParamSpec{{ + Name: "tags", + Flag: "tags", + In: InInput, + GoType: "[]string", + }}, + Steps: []WorkflowStepSpec{{ + ID: "check", + Operation: CommandSpec{ + Group: "System", + Use: "check", + Method: "GET", + PathTpl: "/check", + Params: []ParamSpec{{ + Name: "tag", + Flag: "tag", + In: InQuery, + GoType: "[]string", + }}, + Security: &SecurityHint{Public: true}, + }, + Params: map[string]string{"tag": "${input.tags}"}, + }}, + }}); err != nil { + t.Fatalf("BuildWorkflows: %v", err) + } + root.SetArgs([]string{"--hostname", srv.URL, "doctor", "--tags", "a", "--tags", "b"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if !reflect.DeepEqual(tags, []string{"a", "b"}) { + t.Fatalf("tags = %#v", tags) + } +} + +func newWorkflowRoot(out io.Writer) *cobra.Command { + root := newRootWithModuleGroup() + root.SetOut(out) + root.SetErr(io.Discard) + root.PersistentFlags().String("hostname", "", "") + root.PersistentFlags().StringP("output", "o", "raw", "") + return root +} + +func publicGetSpec(use string, path string) CommandSpec { + return CommandSpec{ + Group: "System", + Use: use, + Method: "GET", + PathTpl: path, + Security: &SecurityHint{Public: true}, + } +}