From 5b9c322a4322ba066691338882e54a4f6e7e5772 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Fri, 24 Jan 2025 18:13:24 -0500 Subject: [PATCH 01/11] Fix #222 - (WIP): Basic raw implementation for DSL 1.0.0 Signed-off-by: Ricardo Zanini --- expr/expr.go | 95 ++++++ impl/context.go | 93 ++++++ impl/impl.go | 90 +++++ impl/task.go | 43 +++ impl/task_set_test.go | 401 +++++++++++++++++++++++ impl/task_test.go | 73 +++++ impl/testdata/chained_set_tasks.yaml | 15 + impl/testdata/concatenating_strings.yaml | 17 + impl/testdata/conditional_logic.yaml | 12 + impl/testdata/sequential_set_colors.yaml | 15 + impl/utils.go | 24 ++ model/runtime_expression.go | 18 +- 12 files changed, 880 insertions(+), 16 deletions(-) create mode 100644 expr/expr.go create mode 100644 impl/context.go create mode 100644 impl/impl.go create mode 100644 impl/task.go create mode 100644 impl/task_set_test.go create mode 100644 impl/task_test.go create mode 100644 impl/testdata/chained_set_tasks.yaml create mode 100644 impl/testdata/concatenating_strings.yaml create mode 100644 impl/testdata/conditional_logic.yaml create mode 100644 impl/testdata/sequential_set_colors.yaml create mode 100644 impl/utils.go diff --git a/expr/expr.go b/expr/expr.go new file mode 100644 index 0000000..e54fc3e --- /dev/null +++ b/expr/expr.go @@ -0,0 +1,95 @@ +package expr + +import ( + "errors" + "fmt" + "github.com/itchyny/gojq" + "strings" +) + +// IsStrictExpr returns true if the string is enclosed in `${ }` +func IsStrictExpr(expression string) bool { + return strings.HasPrefix(expression, "${") && strings.HasSuffix(expression, "}") +} + +// Sanitize processes the expression to ensure it's ready for evaluation +// It removes `${}` if present and replaces single quotes with double quotes +func Sanitize(expression string) string { + // Remove `${}` enclosure if present + if IsStrictExpr(expression) { + expression = strings.TrimSpace(expression[2 : len(expression)-1]) + } + + // Replace single quotes with double quotes + expression = strings.ReplaceAll(expression, "'", "\"") + + return expression +} + +// IsValid tries to parse and check if the given value is a valid expression +func IsValid(expression string) bool { + expression = Sanitize(expression) + _, err := gojq.Parse(expression) + return err == nil +} + +// TraverseAndEvaluate recursively processes and evaluates all expressions in a JSON-like structure +func TraverseAndEvaluate(node interface{}, input map[string]interface{}) (interface{}, error) { + switch v := node.(type) { + case map[string]interface{}: + // Traverse map + for key, value := range v { + evaluatedValue, err := TraverseAndEvaluate(value, input) + if err != nil { + return nil, err + } + v[key] = evaluatedValue + } + return v, nil + + case []interface{}: + // Traverse array + for i, value := range v { + evaluatedValue, err := TraverseAndEvaluate(value, input) + if err != nil { + return nil, err + } + v[i] = evaluatedValue + } + return v, nil + + case string: + // Check if the string is a runtime expression (e.g., ${ .some.path }) + if IsStrictExpr(v) { + return EvaluateJQExpression(Sanitize(v), input) + } + return v, nil + + default: + // Return other types as-is + return v, nil + } +} + +// EvaluateJQExpression evaluates a jq expression against a given JSON input +func EvaluateJQExpression(expression string, input map[string]interface{}) (interface{}, error) { + // Parse the sanitized jq expression + query, err := gojq.Parse(expression) + if err != nil { + return nil, fmt.Errorf("failed to parse jq expression: %s, error: %w", expression, err) + } + + // Compile and evaluate the expression + iter := query.Run(input) + result, ok := iter.Next() + if !ok { + return nil, errors.New("no result from jq evaluation") + } + + // Check if an error occurred during evaluation + if err, isErr := result.(error); isErr { + return nil, fmt.Errorf("jq evaluation error: %w", err) + } + + return result, nil +} diff --git a/impl/context.go b/impl/context.go new file mode 100644 index 0000000..93d4872 --- /dev/null +++ b/impl/context.go @@ -0,0 +1,93 @@ +package impl + +import ( + "context" + "errors" + "sync" +) + +type ctxKey string + +const executorCtxKey ctxKey = "executorContext" + +// ExecutorContext to not confound with Workflow Context as "$context" in the specification. +// This holds the necessary data for the workflow execution within the instance. +type ExecutorContext struct { + mu sync.Mutex + Input map[string]interface{} + Output map[string]interface{} + // Context or `$context` passed through the task executions see https://github.com/serverlessworkflow/specification/blob/main/dsl.md#data-flow + Context map[string]interface{} +} + +// SetWorkflowCtx safely sets the $context +func (execCtx *ExecutorContext) SetWorkflowCtx(wfCtx map[string]interface{}) { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + execCtx.Context = wfCtx +} + +// GetWorkflowCtx safely retrieves the $context +func (execCtx *ExecutorContext) GetWorkflowCtx() map[string]interface{} { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + return execCtx.Context +} + +// SetInput safely sets the input map +func (execCtx *ExecutorContext) SetInput(input map[string]interface{}) { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + execCtx.Input = input +} + +// GetInput safely retrieves the input map +func (execCtx *ExecutorContext) GetInput() map[string]interface{} { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + return execCtx.Input +} + +// SetOutput safely sets the output map +func (execCtx *ExecutorContext) SetOutput(output map[string]interface{}) { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + execCtx.Output = output +} + +// GetOutput safely retrieves the output map +func (execCtx *ExecutorContext) GetOutput() map[string]interface{} { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + return execCtx.Output +} + +// UpdateOutput allows adding or updating a single key-value pair in the output map +func (execCtx *ExecutorContext) UpdateOutput(key string, value interface{}) { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + if execCtx.Output == nil { + execCtx.Output = make(map[string]interface{}) + } + execCtx.Output[key] = value +} + +// GetOutputValue safely retrieves a single key from the output map +func (execCtx *ExecutorContext) GetOutputValue(key string) (interface{}, bool) { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + value, exists := execCtx.Output[key] + return value, exists +} + +func WithExecutorContext(parent context.Context, wfCtx *ExecutorContext) context.Context { + return context.WithValue(parent, executorCtxKey, wfCtx) +} + +func GetExecutorContext(ctx context.Context) (*ExecutorContext, error) { + wfCtx, ok := ctx.Value(executorCtxKey).(*ExecutorContext) + if !ok { + return nil, errors.New("workflow context not found") + } + return wfCtx, nil +} diff --git a/impl/impl.go b/impl/impl.go new file mode 100644 index 0000000..604ed8f --- /dev/null +++ b/impl/impl.go @@ -0,0 +1,90 @@ +package impl + +import ( + "context" + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +type StatusPhase string + +const ( + PendingStatus StatusPhase = "pending" + RunningStatus StatusPhase = "running" + WaitingStatus StatusPhase = "waiting" + CancelledStatus StatusPhase = "cancelled" + FaultedStatus StatusPhase = "faulted" + CompletedStatus StatusPhase = "completed" +) + +var _ WorkflowRunner = &workflowRunnerImpl{} + +type WorkflowRunner interface { + GetWorkflow() *model.Workflow + Run(input map[string]interface{}) (output map[string]interface{}, err error) +} + +func NewDefaultRunner(workflow *model.Workflow) WorkflowRunner { + // later we can implement the opts pattern to define context timeout, deadline, cancel, etc. + // also fetch from the workflow model this information + ctx := WithExecutorContext(context.Background(), &ExecutorContext{}) + return &workflowRunnerImpl{ + Workflow: workflow, + Context: ctx, + } +} + +type workflowRunnerImpl struct { + Workflow *model.Workflow + Context context.Context +} + +func (wr *workflowRunnerImpl) GetWorkflow() *model.Workflow { + return wr.Workflow +} + +// Run the workflow. +// TODO: Sync execution, we think about async later +func (wr *workflowRunnerImpl) Run(input map[string]interface{}) (output map[string]interface{}, err error) { + output = make(map[string]interface{}) + if input == nil { + input = make(map[string]interface{}) + } + + // TODO: validates input via wr.Workflow.Input.Schema + + wfCtx, err := GetExecutorContext(wr.Context) + if err != nil { + return nil, err + } + wfCtx.SetInput(input) + wfCtx.SetOutput(output) + + // TODO: process wr.Workflow.Input.From, the result we set to WorkFlowCtx + wfCtx.SetWorkflowCtx(input) + + // Run tasks + // For each task, execute. + if wr.Workflow.Do != nil { + for _, taskItem := range *wr.Workflow.Do { + switch task := taskItem.Task.(type) { + case *model.SetTask: + exec, err := NewSetTaskExecutor(taskItem.Key, task) + if err != nil { + return nil, err + } + output, err = exec.Exec(wfCtx.GetWorkflowCtx()) + if err != nil { + return nil, err + } + wfCtx.SetWorkflowCtx(output) + default: + return nil, fmt.Errorf("workflow does not support task '%T' named '%s'", task, taskItem.Key) + } + } + } + + // Process output and return + + return output, err +} diff --git a/impl/task.go b/impl/task.go new file mode 100644 index 0000000..a50a995 --- /dev/null +++ b/impl/task.go @@ -0,0 +1,43 @@ +package impl + +import ( + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/expr" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +var _ TaskExecutor = &SetTaskExecutor{} + +type TaskExecutor interface { + Exec(input map[string]interface{}) (map[string]interface{}, error) +} + +type SetTaskExecutor struct { + Task *model.SetTask + TaskName string +} + +func NewSetTaskExecutor(taskName string, task *model.SetTask) (*SetTaskExecutor, error) { + if task == nil || task.Set == nil { + return nil, fmt.Errorf("no set configuration provided for SetTask %s", taskName) + } + return &SetTaskExecutor{ + Task: task, + TaskName: taskName, + }, nil +} + +func (s *SetTaskExecutor) Exec(input map[string]interface{}) (output map[string]interface{}, err error) { + setObject := deepClone(s.Task.Set) + result, err := expr.TraverseAndEvaluate(setObject, input) + if err != nil { + return nil, fmt.Errorf("failed to execute Set task '%s': %w", s.TaskName, err) + } + + output, ok := result.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("expected output to be a map[string]interface{}, but got a different type. Got: %v", result) + } + + return output, nil +} diff --git a/impl/task_set_test.go b/impl/task_set_test.go new file mode 100644 index 0000000..269653c --- /dev/null +++ b/impl/task_set_test.go @@ -0,0 +1,401 @@ +package impl + +import ( + "github.com/serverlessworkflow/sdk-go/v3/model" + "github.com/stretchr/testify/assert" + "reflect" + "testing" +) + +func TestSetTaskExecutor_Exec(t *testing.T) { + input := map[string]interface{}{ + "configuration": map[string]interface{}{ + "size": map[string]interface{}{ + "width": 6, + "height": 6, + }, + "fill": map[string]interface{}{ + "red": 69, + "green": 69, + "blue": 69, + }, + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "shape": "circle", + "size": "${ .configuration.size }", + "fill": "${ .configuration.fill }", + }, + } + + executor, err := NewSetTaskExecutor("task1", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "shape": "circle", + "size": map[string]interface{}{ + "width": 6, + "height": 6, + }, + "fill": map[string]interface{}{ + "red": 69, + "green": 69, + "blue": 69, + }, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_StaticValues(t *testing.T) { + input := map[string]interface{}{} + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "status": "completed", + "count": 10, + }, + } + + executor, err := NewSetTaskExecutor("task_static", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "status": "completed", + "count": 10, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_RuntimeExpressions(t *testing.T) { + input := map[string]interface{}{ + "user": map[string]interface{}{ + "firstName": "John", + "lastName": "Doe", + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "fullName": "${ \"\\(.user.firstName) \\(.user.lastName)\" }", + }, + } + + executor, err := NewSetTaskExecutor("task_runtime_expr", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "fullName": "John Doe", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_NestedStructures(t *testing.T) { + input := map[string]interface{}{ + "order": map[string]interface{}{ + "id": 12345, + "items": []interface{}{"item1", "item2"}, + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "orderDetails": map[string]interface{}{ + "orderId": "${ .order.id }", + "itemCount": "${ .order.items | length }", + }, + }, + } + + executor, err := NewSetTaskExecutor("task_nested_structures", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "orderDetails": map[string]interface{}{ + "orderId": 12345, + "itemCount": 2, + }, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_StaticAndDynamicValues(t *testing.T) { + input := map[string]interface{}{ + "config": map[string]interface{}{ + "threshold": 100, + }, + "metrics": map[string]interface{}{ + "current": 75, + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "status": "active", + "remaining": "${ .config.threshold - .metrics.current }", + }, + } + + executor, err := NewSetTaskExecutor("task_static_dynamic", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "status": "active", + "remaining": 25, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_MissingInputData(t *testing.T) { + input := map[string]interface{}{} + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "value": "${ .missingField }", + }, + } + + executor, err := NewSetTaskExecutor("task_missing_input", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + assert.Nil(t, output["value"]) +} + +func TestSetTaskExecutor_ExpressionsWithFunctions(t *testing.T) { + input := map[string]interface{}{ + "values": []interface{}{1, 2, 3, 4, 5}, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "sum": "${ .values | map(.) | add }", + }, + } + + executor, err := NewSetTaskExecutor("task_expr_functions", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "sum": 15, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_ConditionalExpressions(t *testing.T) { + input := map[string]interface{}{ + "temperature": 30, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "weather": "${ if .temperature > 25 then 'hot' else 'cold' end }", + }, + } + + executor, err := NewSetTaskExecutor("task_conditional_expr", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "weather": "hot", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_ArrayDynamicIndex(t *testing.T) { + input := map[string]interface{}{ + "items": []interface{}{"apple", "banana", "cherry"}, + "index": 1, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "selectedItem": "${ .items[.index] }", + }, + } + + executor, err := NewSetTaskExecutor("task_array_indexing", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "selectedItem": "banana", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_NestedConditionalLogic(t *testing.T) { + input := map[string]interface{}{ + "age": 20, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "status": "${ if .age < 18 then 'minor' else if .age < 65 then 'adult' else 'senior' end end }", + }, + } + + executor, err := NewSetTaskExecutor("task_nested_condition", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "status": "adult", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_DefaultValues(t *testing.T) { + input := map[string]interface{}{} + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "value": "${ .missingField // 'defaultValue' }", + }, + } + + executor, err := NewSetTaskExecutor("task_default_values", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "value": "defaultValue", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_ComplexNestedStructures(t *testing.T) { + input := map[string]interface{}{ + "config": map[string]interface{}{ + "dimensions": map[string]interface{}{ + "width": 10, + "height": 5, + }, + }, + "meta": map[string]interface{}{ + "color": "blue", + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "shape": map[string]interface{}{ + "type": "rectangle", + "width": "${ .config.dimensions.width }", + "height": "${ .config.dimensions.height }", + "color": "${ .meta.color }", + "area": "${ .config.dimensions.width * .config.dimensions.height }", + }, + }, + } + + executor, err := NewSetTaskExecutor("task_complex_nested", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "shape": map[string]interface{}{ + "type": "rectangle", + "width": 10, + "height": 5, + "color": "blue", + "area": 50, + }, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_MultipleExpressions(t *testing.T) { + input := map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Alice", + "email": "alice@example.com", + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "username": "${ .user.name }", + "contact": "${ .user.email }", + }, + } + + executor, err := NewSetTaskExecutor("task_multiple_expr", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "username": "Alice", + "contact": "alice@example.com", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} diff --git a/impl/task_test.go b/impl/task_test.go new file mode 100644 index 0000000..78b89a9 --- /dev/null +++ b/impl/task_test.go @@ -0,0 +1,73 @@ +package impl + +import ( + "github.com/serverlessworkflow/sdk-go/v3/parser" + "github.com/stretchr/testify/assert" + "io/ioutil" + "path/filepath" + "testing" +) + +// runWorkflowTest is a reusable test function for workflows +func runWorkflowTest(t *testing.T, workflowPath string, input, expectedOutput map[string]interface{}) { + // Read the workflow YAML from the testdata directory + yamlBytes, err := ioutil.ReadFile(filepath.Clean(workflowPath)) + assert.NoError(t, err, "Failed to read workflow YAML file") + + // Parse the YAML workflow + workflow, err := parser.FromYAMLSource(yamlBytes) + assert.NoError(t, err, "Failed to parse workflow YAML") + + // Initialize the workflow runner + runner := NewDefaultRunner(workflow) + + // Run the workflow + output, err := runner.Run(input) + + // Assertions + assert.NoError(t, err) + assert.Equal(t, expectedOutput, output, "Workflow output mismatch") +} + +// TestWorkflowRunner_Run_YAML validates multiple workflows +func TestWorkflowRunner_Run_YAML(t *testing.T) { + // Workflow 1: Chained Set Tasks + t.Run("Chained Set Tasks", func(t *testing.T) { + workflowPath := "./testdata/chained_set_tasks.yaml" + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "tripled": float64(60), + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + // Workflow 2: Concatenating Strings + t.Run("Concatenating Strings", func(t *testing.T) { + workflowPath := "./testdata/concatenating_strings.yaml" + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "fullName": "John Doe", + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + // Workflow 3: Conditional Logic + t.Run("Conditional Logic", func(t *testing.T) { + workflowPath := "./testdata/conditional_logic.yaml" + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "weather": "hot", + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("Conditional Logic", func(t *testing.T) { + workflowPath := "./testdata/sequential_set_colors.yaml" + // Define the input and expected output + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "colors": []interface{}{"red", "green", "blue"}, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) +} diff --git a/impl/testdata/chained_set_tasks.yaml b/impl/testdata/chained_set_tasks.yaml new file mode 100644 index 0000000..b1388dd --- /dev/null +++ b/impl/testdata/chained_set_tasks.yaml @@ -0,0 +1,15 @@ +document: + name: chained-workflow + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + baseValue: 10 + - task2: + set: + doubled: "${ .baseValue * 2 }" + - task3: + set: + tripled: "${ .doubled * 3 }" diff --git a/impl/testdata/concatenating_strings.yaml b/impl/testdata/concatenating_strings.yaml new file mode 100644 index 0000000..a0b3a84 --- /dev/null +++ b/impl/testdata/concatenating_strings.yaml @@ -0,0 +1,17 @@ +document: + name: concatenating-strings + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + firstName: "John" + lastName: "" + - task2: + set: + firstName: "${ .firstName }" + lastName: "Doe" + - task3: + set: + fullName: "${ .firstName + ' ' + .lastName }" diff --git a/impl/testdata/conditional_logic.yaml b/impl/testdata/conditional_logic.yaml new file mode 100644 index 0000000..dfff8f8 --- /dev/null +++ b/impl/testdata/conditional_logic.yaml @@ -0,0 +1,12 @@ +document: + name: conditional-logic + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + temperature: 30 + - task2: + set: + weather: "${ if .temperature > 25 then 'hot' else 'cold' end }" diff --git a/impl/testdata/sequential_set_colors.yaml b/impl/testdata/sequential_set_colors.yaml new file mode 100644 index 0000000..73162cc --- /dev/null +++ b/impl/testdata/sequential_set_colors.yaml @@ -0,0 +1,15 @@ +document: + dsl: '1.0.0-alpha5' + namespace: default + name: do + version: '1.0.0' +do: + - setRed: + set: + colors: ${ .colors + ["red"] } + - setGreen: + set: + colors: ${ .colors + ["green"] } + - setBlue: + set: + colors: ${ .colors + ["blue"] } \ No newline at end of file diff --git a/impl/utils.go b/impl/utils.go new file mode 100644 index 0000000..fa80ef9 --- /dev/null +++ b/impl/utils.go @@ -0,0 +1,24 @@ +package impl + +// Deep clone a map to avoid modifying the original object +func deepClone(obj map[string]interface{}) map[string]interface{} { + clone := make(map[string]interface{}) + for key, value := range obj { + clone[key] = deepCloneValue(value) + } + return clone +} + +func deepCloneValue(value interface{}) interface{} { + if m, ok := value.(map[string]interface{}); ok { + return deepClone(m) + } + if s, ok := value.([]interface{}); ok { + clonedSlice := make([]interface{}, len(s)) + for i, v := range s { + clonedSlice[i] = deepCloneValue(v) + } + return clonedSlice + } + return value +} diff --git a/model/runtime_expression.go b/model/runtime_expression.go index c67a3ef..f7ba5c8 100644 --- a/model/runtime_expression.go +++ b/model/runtime_expression.go @@ -17,8 +17,7 @@ package model import ( "encoding/json" "fmt" - "github.com/itchyny/gojq" - "strings" + "github.com/serverlessworkflow/sdk-go/v3/expr" ) // RuntimeExpression represents a runtime expression. @@ -34,22 +33,9 @@ func NewExpr(runtimeExpression string) *RuntimeExpression { return &RuntimeExpression{Value: runtimeExpression} } -// preprocessExpression removes `${}` if present and returns the inner content. -func preprocessExpression(expression string) string { - if strings.HasPrefix(expression, "${") && strings.HasSuffix(expression, "}") { - return strings.TrimSpace(expression[2 : len(expression)-1]) - } - return expression // Return the expression as-is if `${}` are not present -} - // IsValid checks if the RuntimeExpression value is valid, handling both with and without `${}`. func (r *RuntimeExpression) IsValid() bool { - // Preprocess to extract content inside `${}` if present - processedExpr := preprocessExpression(r.Value) - - // Validate the processed expression using gojq - _, err := gojq.Parse(processedExpr) - return err == nil + return expr.IsValid(r.Value) } // UnmarshalJSON implements custom unmarshalling for RuntimeExpression. From feeb8dbdc3dd17e0905b3857486ddb1e713293a8 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Thu, 30 Jan 2025 17:47:30 -0500 Subject: [PATCH 02/11] Evaluate expressions, statusphase, schema validation, export, as, from Signed-off-by: Ricardo Zanini --- expr/expr.go | 4 +- go.mod | 3 + go.sum | 10 + impl/context.go | 154 +++++---- impl/impl.go | 90 ------ impl/json_schema.go | 55 ++++ impl/runner.go | 263 +++++++++++++++ impl/status_phase.go | 38 +++ impl/task.go | 37 ++- impl/task_set_test.go | 54 ++-- impl/task_test.go | 133 +++++++- .../conditional_logic_input_from.yaml | 11 + impl/testdata/sequential_set_colors.yaml | 4 +- .../sequential_set_colors_output_as.yaml | 17 + impl/testdata/task_export_schema.yaml | 18 ++ impl/testdata/task_input_schema.yaml | 18 ++ impl/testdata/task_output_schema.yaml | 18 ++ ...task_output_schema_with_dynamic_value.yaml | 18 ++ impl/testdata/workflow_input_schema.yaml | 18 ++ model/endpoint_test.go | 10 +- model/errors.go | 300 ++++++++++++++++++ model/errors_test.go | 124 ++++++++ model/objects.go | 27 ++ model/task.go | 97 ++---- model/task_call.go | 20 ++ model/task_do.go | 4 + model/task_event.go | 8 + model/task_for.go | 4 + model/task_fork.go | 4 + model/task_raise.go | 20 +- model/task_run.go | 4 + model/task_set.go | 4 + model/task_switch.go | 4 + model/task_try.go | 4 + model/task_wait.go | 4 + model/workflow_test.go | 16 +- 36 files changed, 1325 insertions(+), 292 deletions(-) delete mode 100644 impl/impl.go create mode 100644 impl/json_schema.go create mode 100644 impl/runner.go create mode 100644 impl/status_phase.go create mode 100644 impl/testdata/conditional_logic_input_from.yaml create mode 100644 impl/testdata/sequential_set_colors_output_as.yaml create mode 100644 impl/testdata/task_export_schema.yaml create mode 100644 impl/testdata/task_input_schema.yaml create mode 100644 impl/testdata/task_output_schema.yaml create mode 100644 impl/testdata/task_output_schema_with_dynamic_value.yaml create mode 100644 impl/testdata/workflow_input_schema.yaml create mode 100644 model/errors.go create mode 100644 model/errors_test.go diff --git a/expr/expr.go b/expr/expr.go index e54fc3e..d8f2d31 100644 --- a/expr/expr.go +++ b/expr/expr.go @@ -34,7 +34,7 @@ func IsValid(expression string) bool { } // TraverseAndEvaluate recursively processes and evaluates all expressions in a JSON-like structure -func TraverseAndEvaluate(node interface{}, input map[string]interface{}) (interface{}, error) { +func TraverseAndEvaluate(node interface{}, input interface{}) (interface{}, error) { switch v := node.(type) { case map[string]interface{}: // Traverse map @@ -72,7 +72,7 @@ func TraverseAndEvaluate(node interface{}, input map[string]interface{}) (interf } // EvaluateJQExpression evaluates a jq expression against a given JSON input -func EvaluateJQExpression(expression string, input map[string]interface{}) (interface{}, error) { +func EvaluateJQExpression(expression string, input interface{}) (interface{}, error) { // Parse the sanitized jq expression query, err := gojq.Parse(expression) if err != nil { diff --git a/go.mod b/go.mod index fc847fa..15c63e3 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,9 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect golang.org/x/crypto v0.32.0 // indirect golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.29.0 // indirect diff --git a/go.sum b/go.sum index 257234a..3a19f04 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= @@ -19,8 +20,11 @@ github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/my github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -30,6 +34,12 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= diff --git a/impl/context.go b/impl/context.go index 93d4872..b002c9f 100644 --- a/impl/context.go +++ b/impl/context.go @@ -8,84 +8,124 @@ import ( type ctxKey string -const executorCtxKey ctxKey = "executorContext" - -// ExecutorContext to not confound with Workflow Context as "$context" in the specification. -// This holds the necessary data for the workflow execution within the instance. -type ExecutorContext struct { - mu sync.Mutex - Input map[string]interface{} - Output map[string]interface{} - // Context or `$context` passed through the task executions see https://github.com/serverlessworkflow/specification/blob/main/dsl.md#data-flow - Context map[string]interface{} +const runnerCtxKey ctxKey = "wfRunnerContext" + +// WorkflowRunnerContext holds the necessary data for the workflow execution within the instance. +type WorkflowRunnerContext struct { + mu sync.Mutex + input interface{} // input can hold any type + output interface{} // output can hold any type + context map[string]interface{} + StatusPhase []StatusPhaseLog + TasksStatusPhase map[string][]StatusPhaseLog // Holds `$context` as the key +} + +func (runnerCtx *WorkflowRunnerContext) SetStatus(status StatusPhase) { + runnerCtx.mu.Lock() + defer runnerCtx.mu.Unlock() + if runnerCtx.StatusPhase == nil { + runnerCtx.StatusPhase = []StatusPhaseLog{} + } + runnerCtx.StatusPhase = append(runnerCtx.StatusPhase, NewStatusPhaseLog(status)) } -// SetWorkflowCtx safely sets the $context -func (execCtx *ExecutorContext) SetWorkflowCtx(wfCtx map[string]interface{}) { - execCtx.mu.Lock() - defer execCtx.mu.Unlock() - execCtx.Context = wfCtx +func (runnerCtx *WorkflowRunnerContext) SetTaskStatus(task string, status StatusPhase) { + runnerCtx.mu.Lock() + defer runnerCtx.mu.Unlock() + if runnerCtx.TasksStatusPhase == nil { + runnerCtx.TasksStatusPhase = map[string][]StatusPhaseLog{} + } + runnerCtx.TasksStatusPhase[task] = append(runnerCtx.TasksStatusPhase[task], NewStatusPhaseLog(status)) +} + +// SetWorkflowCtx safely sets the `$context` value +func (runnerCtx *WorkflowRunnerContext) SetWorkflowCtx(value interface{}) { + runnerCtx.mu.Lock() + defer runnerCtx.mu.Unlock() + if runnerCtx.context == nil { + runnerCtx.context = make(map[string]interface{}) + } + runnerCtx.context["$context"] = value } -// GetWorkflowCtx safely retrieves the $context -func (execCtx *ExecutorContext) GetWorkflowCtx() map[string]interface{} { - execCtx.mu.Lock() - defer execCtx.mu.Unlock() - return execCtx.Context +// GetWorkflowCtx safely retrieves the `$context` value +func (runnerCtx *WorkflowRunnerContext) GetWorkflowCtx() interface{} { + runnerCtx.mu.Lock() + defer runnerCtx.mu.Unlock() + if runnerCtx.context == nil { + return nil + } + return runnerCtx.context["$context"] } -// SetInput safely sets the input map -func (execCtx *ExecutorContext) SetInput(input map[string]interface{}) { - execCtx.mu.Lock() - defer execCtx.mu.Unlock() - execCtx.Input = input +// SetInput safely sets the input +func (runnerCtx *WorkflowRunnerContext) SetInput(input interface{}) { + runnerCtx.mu.Lock() + defer runnerCtx.mu.Unlock() + runnerCtx.input = input } -// GetInput safely retrieves the input map -func (execCtx *ExecutorContext) GetInput() map[string]interface{} { - execCtx.mu.Lock() - defer execCtx.mu.Unlock() - return execCtx.Input +// GetInput safely retrieves the input +func (runnerCtx *WorkflowRunnerContext) GetInput() interface{} { + runnerCtx.mu.Lock() + defer runnerCtx.mu.Unlock() + return runnerCtx.input } -// SetOutput safely sets the output map -func (execCtx *ExecutorContext) SetOutput(output map[string]interface{}) { - execCtx.mu.Lock() - defer execCtx.mu.Unlock() - execCtx.Output = output +// SetOutput safely sets the output +func (runnerCtx *WorkflowRunnerContext) SetOutput(output interface{}) { + runnerCtx.mu.Lock() + defer runnerCtx.mu.Unlock() + runnerCtx.output = output } -// GetOutput safely retrieves the output map -func (execCtx *ExecutorContext) GetOutput() map[string]interface{} { - execCtx.mu.Lock() - defer execCtx.mu.Unlock() - return execCtx.Output +// GetOutput safely retrieves the output +func (runnerCtx *WorkflowRunnerContext) GetOutput() interface{} { + runnerCtx.mu.Lock() + defer runnerCtx.mu.Unlock() + return runnerCtx.output } -// UpdateOutput allows adding or updating a single key-value pair in the output map -func (execCtx *ExecutorContext) UpdateOutput(key string, value interface{}) { - execCtx.mu.Lock() - defer execCtx.mu.Unlock() - if execCtx.Output == nil { - execCtx.Output = make(map[string]interface{}) +// GetInputAsMap safely retrieves the input as a map[string]interface{}. +// If input is not a map, it creates a map with an empty string key and the input as the value. +func (runnerCtx *WorkflowRunnerContext) GetInputAsMap() map[string]interface{} { + runnerCtx.mu.Lock() + defer runnerCtx.mu.Unlock() + + if inputMap, ok := runnerCtx.input.(map[string]interface{}); ok { + return inputMap + } + + // If input is not a map, create a map with an empty key and set input as the value + return map[string]interface{}{ + "": runnerCtx.input, } - execCtx.Output[key] = value } -// GetOutputValue safely retrieves a single key from the output map -func (execCtx *ExecutorContext) GetOutputValue(key string) (interface{}, bool) { - execCtx.mu.Lock() - defer execCtx.mu.Unlock() - value, exists := execCtx.Output[key] - return value, exists +// GetOutputAsMap safely retrieves the output as a map[string]interface{}. +// If output is not a map, it creates a map with an empty string key and the output as the value. +func (runnerCtx *WorkflowRunnerContext) GetOutputAsMap() map[string]interface{} { + runnerCtx.mu.Lock() + defer runnerCtx.mu.Unlock() + + if outputMap, ok := runnerCtx.output.(map[string]interface{}); ok { + return outputMap + } + + // If output is not a map, create a map with an empty key and set output as the value + return map[string]interface{}{ + "": runnerCtx.output, + } } -func WithExecutorContext(parent context.Context, wfCtx *ExecutorContext) context.Context { - return context.WithValue(parent, executorCtxKey, wfCtx) +// WithRunnerContext adds the WorkflowRunnerContext to a parent context +func WithRunnerContext(parent context.Context, wfCtx *WorkflowRunnerContext) context.Context { + return context.WithValue(parent, runnerCtxKey, wfCtx) } -func GetExecutorContext(ctx context.Context) (*ExecutorContext, error) { - wfCtx, ok := ctx.Value(executorCtxKey).(*ExecutorContext) +// GetRunnerContext retrieves the WorkflowRunnerContext from a context +func GetRunnerContext(ctx context.Context) (*WorkflowRunnerContext, error) { + wfCtx, ok := ctx.Value(runnerCtxKey).(*WorkflowRunnerContext) if !ok { return nil, errors.New("workflow context not found") } diff --git a/impl/impl.go b/impl/impl.go deleted file mode 100644 index 604ed8f..0000000 --- a/impl/impl.go +++ /dev/null @@ -1,90 +0,0 @@ -package impl - -import ( - "context" - "fmt" - "github.com/serverlessworkflow/sdk-go/v3/model" -) - -type StatusPhase string - -const ( - PendingStatus StatusPhase = "pending" - RunningStatus StatusPhase = "running" - WaitingStatus StatusPhase = "waiting" - CancelledStatus StatusPhase = "cancelled" - FaultedStatus StatusPhase = "faulted" - CompletedStatus StatusPhase = "completed" -) - -var _ WorkflowRunner = &workflowRunnerImpl{} - -type WorkflowRunner interface { - GetWorkflow() *model.Workflow - Run(input map[string]interface{}) (output map[string]interface{}, err error) -} - -func NewDefaultRunner(workflow *model.Workflow) WorkflowRunner { - // later we can implement the opts pattern to define context timeout, deadline, cancel, etc. - // also fetch from the workflow model this information - ctx := WithExecutorContext(context.Background(), &ExecutorContext{}) - return &workflowRunnerImpl{ - Workflow: workflow, - Context: ctx, - } -} - -type workflowRunnerImpl struct { - Workflow *model.Workflow - Context context.Context -} - -func (wr *workflowRunnerImpl) GetWorkflow() *model.Workflow { - return wr.Workflow -} - -// Run the workflow. -// TODO: Sync execution, we think about async later -func (wr *workflowRunnerImpl) Run(input map[string]interface{}) (output map[string]interface{}, err error) { - output = make(map[string]interface{}) - if input == nil { - input = make(map[string]interface{}) - } - - // TODO: validates input via wr.Workflow.Input.Schema - - wfCtx, err := GetExecutorContext(wr.Context) - if err != nil { - return nil, err - } - wfCtx.SetInput(input) - wfCtx.SetOutput(output) - - // TODO: process wr.Workflow.Input.From, the result we set to WorkFlowCtx - wfCtx.SetWorkflowCtx(input) - - // Run tasks - // For each task, execute. - if wr.Workflow.Do != nil { - for _, taskItem := range *wr.Workflow.Do { - switch task := taskItem.Task.(type) { - case *model.SetTask: - exec, err := NewSetTaskExecutor(taskItem.Key, task) - if err != nil { - return nil, err - } - output, err = exec.Exec(wfCtx.GetWorkflowCtx()) - if err != nil { - return nil, err - } - wfCtx.SetWorkflowCtx(output) - default: - return nil, fmt.Errorf("workflow does not support task '%T' named '%s'", task, taskItem.Key) - } - } - } - - // Process output and return - - return output, err -} diff --git a/impl/json_schema.go b/impl/json_schema.go new file mode 100644 index 0000000..c0f62ad --- /dev/null +++ b/impl/json_schema.go @@ -0,0 +1,55 @@ +package impl + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/model" + "github.com/xeipuuv/gojsonschema" +) + +// ValidateJSONSchema validates the provided data against a model.Schema. +func ValidateJSONSchema(data interface{}, schema *model.Schema) error { + if schema == nil { + return nil + } + + schema.ApplyDefaults() + + if schema.Format != model.DefaultSchema { + return fmt.Errorf("unsupported schema format: '%s'", schema.Format) + } + + var schemaJSON string + if schema.Document != nil { + documentBytes, err := json.Marshal(schema.Document) + if err != nil { + return fmt.Errorf("failed to marshal schema document to JSON: %w", err) + } + schemaJSON = string(documentBytes) + } else if schema.Resource != nil { + // TODO: Handle external resource references (not implemented here) + return errors.New("external resources are not yet supported") + } else { + return errors.New("schema must have either a 'Document' or 'Resource'") + } + + schemaLoader := gojsonschema.NewStringLoader(schemaJSON) + dataLoader := gojsonschema.NewGoLoader(data) + + result, err := gojsonschema.Validate(schemaLoader, dataLoader) + if err != nil { + // TODO: use model.Error + return fmt.Errorf("failed to validate JSON schema: %w", err) + } + + if !result.Valid() { + var validationErrors string + for _, err := range result.Errors() { + validationErrors += fmt.Sprintf("- %s\n", err.String()) + } + return fmt.Errorf("JSON schema validation failed:\n%s", validationErrors) + } + + return nil +} diff --git a/impl/runner.go b/impl/runner.go new file mode 100644 index 0000000..e563efa --- /dev/null +++ b/impl/runner.go @@ -0,0 +1,263 @@ +package impl + +import ( + "context" + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/expr" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +var _ WorkflowRunner = &workflowRunnerImpl{} + +type WorkflowRunner interface { + GetWorkflowDef() *model.Workflow + Run(input interface{}) (output interface{}, err error) + GetContext() *WorkflowRunnerContext +} + +func NewDefaultRunner(workflow *model.Workflow) WorkflowRunner { + wfContext := &WorkflowRunnerContext{} + wfContext.SetStatus(PendingStatus) + // TODO: based on the workflow definition, the context might change. + ctx := WithRunnerContext(context.Background(), wfContext) + return &workflowRunnerImpl{ + Workflow: workflow, + Context: ctx, + RunnerCtx: wfContext, + } +} + +type workflowRunnerImpl struct { + Workflow *model.Workflow + Context context.Context + RunnerCtx *WorkflowRunnerContext +} + +func (wr *workflowRunnerImpl) GetContext() *WorkflowRunnerContext { + return wr.RunnerCtx +} + +func (wr *workflowRunnerImpl) GetWorkflowDef() *model.Workflow { + return wr.Workflow +} + +// Run executes the workflow synchronously. +func (wr *workflowRunnerImpl) Run(input interface{}) (output interface{}, err error) { + defer func() { + if err != nil { + wr.RunnerCtx.SetStatus(FaultedStatus) + err = wr.wrapWorkflowError(err, "/") + } + }() + + // Process input + if input, err = wr.processInput(input); err != nil { + return nil, err + } + + // Run tasks sequentially + wr.RunnerCtx.SetStatus(RunningStatus) + if err = wr.executeTasks(wr.Workflow.Do); err != nil { + return nil, err + } + + output = wr.RunnerCtx.GetOutput() + + // Process output + if output, err = wr.processOutput(output); err != nil { + return nil, err + } + + wr.RunnerCtx.SetStatus(CompletedStatus) + return output, nil +} + +// processInput validates and transforms input if needed. +func (wr *workflowRunnerImpl) processInput(input interface{}) (interface{}, error) { + if wr.Workflow.Input != nil { + var err error + if err = validateSchema(input, wr.Workflow.Input.Schema, "/"); err != nil { + return nil, err + } + + if wr.Workflow.Input.From != nil { + if input, err = traverseAndEvaluate(wr.Workflow.Input.From, input, "/"); err != nil { + return nil, err + } + wr.RunnerCtx.SetWorkflowCtx(input) + } + } + + wr.RunnerCtx.SetInput(input) + wr.RunnerCtx.SetOutput(input) + return input, nil +} + +// executeTasks runs all defined tasks sequentially. +func (wr *workflowRunnerImpl) executeTasks(tasks *model.TaskList) error { + if tasks == nil { + return nil + } + + // TODO: implement control flow: continue, end, then + for _, taskItem := range *tasks { + wr.RunnerCtx.SetInput(wr.RunnerCtx.GetOutput()) + if shouldRun, err := wr.shouldRunTask(taskItem); err != nil { + return err + } else if !shouldRun { + wr.RunnerCtx.SetOutput(wr.RunnerCtx.GetInput()) + continue + } + + wr.RunnerCtx.SetTaskStatus(taskItem.Key, PendingStatus) + runner, err := NewTaskRunner(taskItem.Key, taskItem.Task) + if err != nil { + return err + } + + wr.RunnerCtx.SetTaskStatus(taskItem.Key, RunningStatus) + var output interface{} + if output, err = wr.runTask(runner, taskItem.Task.GetBase()); err != nil { + wr.RunnerCtx.SetTaskStatus(taskItem.Key, FaultedStatus) + return err + } + + wr.RunnerCtx.SetTaskStatus(taskItem.Key, CompletedStatus) + wr.RunnerCtx.SetOutput(output) + } + + return nil +} + +func (wr *workflowRunnerImpl) shouldRunTask(task *model.TaskItem) (bool, error) { + if task.GetBase().If != nil { + output, err := expr.EvaluateJQExpression(task.GetBase().If.String(), wr.RunnerCtx.GetInput()) + if err != nil { + return false, model.NewErrExpression(err, task.Key) + } + if result, ok := output.(bool); ok && !result { + return false, nil + } + } + return true, nil +} + +// processOutput applies output transformations. +func (wr *workflowRunnerImpl) processOutput(output interface{}) (interface{}, error) { + if wr.Workflow.Output != nil { + var err error + if output, err = traverseAndEvaluate(wr.Workflow.Output.As, wr.RunnerCtx.GetOutput(), "/"); err != nil { + return nil, err + } + + if err = validateSchema(output, wr.Workflow.Output.Schema, "/"); err != nil { + return nil, err + } + } + + wr.RunnerCtx.SetOutput(output) + return output, nil +} + +// ----------------- Task funcs ------------------- // + +// NewTaskRunner creates a TaskRunner instance based on the task type. +func NewTaskRunner(taskName string, task model.Task) (TaskRunner, error) { + switch t := task.(type) { + case *model.SetTask: + return NewSetTaskRunner(taskName, t) + default: + return nil, fmt.Errorf("unsupported task type '%T' for task '%s'", t, taskName) + } +} + +// runTask executes an individual task. +func (wr *workflowRunnerImpl) runTask(runner TaskRunner, task *model.TaskBase) (output interface{}, err error) { + taskInput := wr.RunnerCtx.GetInput() + taskName := runner.GetTaskName() + + defer func() { + if err != nil { + err = wr.wrapWorkflowError(err, taskName) + } + }() + + if task.Input != nil { + if taskInput, err = wr.validateAndEvaluateTaskInput(task, taskInput, taskName); err != nil { + return nil, err + } + } + + output, err = runner.Run(taskInput) + if err != nil { + return nil, err + } + + if output, err = wr.validateAndEvaluateTaskOutput(task, output, taskName); err != nil { + return nil, err + } + + return output, nil +} + +// validateAndEvaluateTaskInput processes task input validation and transformation. +func (wr *workflowRunnerImpl) validateAndEvaluateTaskInput(task *model.TaskBase, taskInput interface{}, taskName string) (output interface{}, err error) { + if task.Input == nil { + return taskInput, nil + } + + if err = validateSchema(taskInput, task.Input.Schema, taskName); err != nil { + return nil, err + } + + if output, err = traverseAndEvaluate(task.Input.From, taskInput, taskName); err != nil { + return nil, err + } + + return output, nil +} + +// validateAndEvaluateTaskOutput processes task output validation and transformation. +func (wr *workflowRunnerImpl) validateAndEvaluateTaskOutput(task *model.TaskBase, taskOutput interface{}, taskName string) (output interface{}, err error) { + if task.Output == nil { + return taskOutput, nil + } + + if output, err = traverseAndEvaluate(task.Output.As, taskOutput, taskName); err != nil { + return nil, err + } + + if err = validateSchema(output, task.Output.Schema, taskName); err != nil { + return nil, err + } + + return output, nil +} + +// wrapWorkflowError ensures workflow errors have a proper instance reference. +func (wr *workflowRunnerImpl) wrapWorkflowError(err error, taskName string) error { + if knownErr := model.AsError(err); knownErr != nil { + return knownErr.WithInstanceRef(wr.Workflow, taskName) + } + return model.NewErrRuntime(err, taskName) +} + +func validateSchema(data interface{}, schema *model.Schema, taskName string) error { + if schema != nil { + if err := ValidateJSONSchema(data, schema); err != nil { + return model.NewErrValidation(err, taskName) + } + } + return nil +} + +func traverseAndEvaluate(runtimeExpr *model.ObjectOrRuntimeExpr, input interface{}, taskName string) (output interface{}, err error) { + if runtimeExpr == nil { + return input, nil + } + output, err = expr.TraverseAndEvaluate(runtimeExpr.AsStringOrMap(), input) + if err != nil { + return nil, model.NewErrExpression(err, taskName) + } + return output, nil +} diff --git a/impl/status_phase.go b/impl/status_phase.go new file mode 100644 index 0000000..f2b8f41 --- /dev/null +++ b/impl/status_phase.go @@ -0,0 +1,38 @@ +package impl + +import "time" + +type StatusPhase string + +const ( + // PendingStatus The workflow/task has been initiated and is pending execution. + PendingStatus StatusPhase = "pending" + // RunningStatus The workflow/task is currently in progress. + RunningStatus StatusPhase = "running" + // WaitingStatus The workflow/task execution is temporarily paused, awaiting either inbound event(s) or a specified time interval as defined by a wait task. + WaitingStatus StatusPhase = "waiting" + // SuspendedStatus The workflow/task execution has been manually paused by a user and will remain halted until explicitly resumed. + SuspendedStatus StatusPhase = "suspended" + // CancelledStatus The workflow/task execution has been terminated before completion. + CancelledStatus StatusPhase = "cancelled" + // FaultedStatus The workflow/task execution has encountered an error. + FaultedStatus StatusPhase = "faulted" + // CompletedStatus The workflow/task ran to completion. + CompletedStatus StatusPhase = "completed" +) + +func (s StatusPhase) String() string { + return string(s) +} + +type StatusPhaseLog struct { + Timestamp int64 `json:"timestamp"` + Status StatusPhase `json:"status"` +} + +func NewStatusPhaseLog(status StatusPhase) StatusPhaseLog { + return StatusPhaseLog{ + Status: status, + Timestamp: time.Now().UnixMilli(), + } +} diff --git a/impl/task.go b/impl/task.go index a50a995..5f31046 100644 --- a/impl/task.go +++ b/impl/task.go @@ -6,37 +6,46 @@ import ( "github.com/serverlessworkflow/sdk-go/v3/model" ) -var _ TaskExecutor = &SetTaskExecutor{} +var _ TaskRunner = &SetTaskRunner{} -type TaskExecutor interface { - Exec(input map[string]interface{}) (map[string]interface{}, error) +type TaskRunner interface { + Run(input interface{}) (interface{}, error) + GetTaskName() string } -type SetTaskExecutor struct { - Task *model.SetTask - TaskName string -} - -func NewSetTaskExecutor(taskName string, task *model.SetTask) (*SetTaskExecutor, error) { +func NewSetTaskRunner(taskName string, task *model.SetTask) (*SetTaskRunner, error) { if task == nil || task.Set == nil { - return nil, fmt.Errorf("no set configuration provided for SetTask %s", taskName) + return nil, model.NewErrValidation(fmt.Errorf("no set configuration provided for SetTask %s", taskName), taskName) } - return &SetTaskExecutor{ + return &SetTaskRunner{ Task: task, TaskName: taskName, }, nil } -func (s *SetTaskExecutor) Exec(input map[string]interface{}) (output map[string]interface{}, err error) { +type SetTaskRunner struct { + Task *model.SetTask + TaskName string +} + +func (s *SetTaskRunner) GetTaskName() string { + return s.TaskName +} + +func (s *SetTaskRunner) String() string { + return fmt.Sprintf("SetTaskRunner{Task: %s}", s.GetTaskName()) +} + +func (s *SetTaskRunner) Run(input interface{}) (output interface{}, err error) { setObject := deepClone(s.Task.Set) result, err := expr.TraverseAndEvaluate(setObject, input) if err != nil { - return nil, fmt.Errorf("failed to execute Set task '%s': %w", s.TaskName, err) + return nil, model.NewErrExpression(err, s.TaskName) } output, ok := result.(map[string]interface{}) if !ok { - return nil, fmt.Errorf("expected output to be a map[string]interface{}, but got a different type. Got: %v", result) + return nil, model.NewErrRuntime(fmt.Errorf("expected output to be a map[string]interface{}, but got a different type. Got: %v", result), s.TaskName) } return output, nil diff --git a/impl/task_set_test.go b/impl/task_set_test.go index 269653c..09dfe1d 100644 --- a/impl/task_set_test.go +++ b/impl/task_set_test.go @@ -30,10 +30,10 @@ func TestSetTaskExecutor_Exec(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task1", setTask) + executor, err := NewSetTaskRunner("task1", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -64,10 +64,10 @@ func TestSetTaskExecutor_StaticValues(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_static", setTask) + executor, err := NewSetTaskRunner("task_static", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -94,10 +94,10 @@ func TestSetTaskExecutor_RuntimeExpressions(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_runtime_expr", setTask) + executor, err := NewSetTaskRunner("task_runtime_expr", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -126,10 +126,10 @@ func TestSetTaskExecutor_NestedStructures(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_nested_structures", setTask) + executor, err := NewSetTaskRunner("task_nested_structures", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -161,10 +161,10 @@ func TestSetTaskExecutor_StaticAndDynamicValues(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_static_dynamic", setTask) + executor, err := NewSetTaskRunner("task_static_dynamic", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -186,12 +186,12 @@ func TestSetTaskExecutor_MissingInputData(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_missing_input", setTask) + executor, err := NewSetTaskRunner("task_missing_input", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) - assert.Nil(t, output["value"]) + assert.Nil(t, output.(map[string]interface{})["value"]) } func TestSetTaskExecutor_ExpressionsWithFunctions(t *testing.T) { @@ -205,10 +205,10 @@ func TestSetTaskExecutor_ExpressionsWithFunctions(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_expr_functions", setTask) + executor, err := NewSetTaskRunner("task_expr_functions", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -231,10 +231,10 @@ func TestSetTaskExecutor_ConditionalExpressions(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_conditional_expr", setTask) + executor, err := NewSetTaskRunner("task_conditional_expr", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -258,10 +258,10 @@ func TestSetTaskExecutor_ArrayDynamicIndex(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_array_indexing", setTask) + executor, err := NewSetTaskRunner("task_array_indexing", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -284,10 +284,10 @@ func TestSetTaskExecutor_NestedConditionalLogic(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_nested_condition", setTask) + executor, err := NewSetTaskRunner("task_nested_condition", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -308,10 +308,10 @@ func TestSetTaskExecutor_DefaultValues(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_default_values", setTask) + executor, err := NewSetTaskRunner("task_default_values", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -348,10 +348,10 @@ func TestSetTaskExecutor_ComplexNestedStructures(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_complex_nested", setTask) + executor, err := NewSetTaskRunner("task_complex_nested", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -384,10 +384,10 @@ func TestSetTaskExecutor_MultipleExpressions(t *testing.T) { }, } - executor, err := NewSetTaskExecutor("task_multiple_expr", setTask) + executor, err := NewSetTaskRunner("task_multiple_expr", setTask) assert.NoError(t, err) - output, err := executor.Exec(input) + output, err := executor.Run(input) assert.NoError(t, err) expectedOutput := map[string]interface{}{ diff --git a/impl/task_test.go b/impl/task_test.go index 78b89a9..3551c01 100644 --- a/impl/task_test.go +++ b/impl/task_test.go @@ -3,7 +3,7 @@ package impl import ( "github.com/serverlessworkflow/sdk-go/v3/parser" "github.com/stretchr/testify/assert" - "io/ioutil" + "os" "path/filepath" "testing" ) @@ -11,7 +11,7 @@ import ( // runWorkflowTest is a reusable test function for workflows func runWorkflowTest(t *testing.T, workflowPath string, input, expectedOutput map[string]interface{}) { // Read the workflow YAML from the testdata directory - yamlBytes, err := ioutil.ReadFile(filepath.Clean(workflowPath)) + yamlBytes, err := os.ReadFile(filepath.Clean(workflowPath)) assert.NoError(t, err, "Failed to read workflow YAML file") // Parse the YAML workflow @@ -66,7 +66,134 @@ func TestWorkflowRunner_Run_YAML(t *testing.T) { // Define the input and expected output input := map[string]interface{}{} expectedOutput := map[string]interface{}{ - "colors": []interface{}{"red", "green", "blue"}, + "resultColors": []interface{}{"red", "green", "blue"}, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + t.Run("input From", func(t *testing.T) { + workflowPath := "./testdata/sequential_set_colors_output_as.yaml" + // Define the input and expected output + expectedOutput := map[string]interface{}{ + "result": []interface{}{"red", "green", "blue"}, + } + runWorkflowTest(t, workflowPath, nil, expectedOutput) + }) + t.Run("input From", func(t *testing.T) { + workflowPath := "./testdata/conditional_logic_input_from.yaml" + // Define the input and expected output + input := map[string]interface{}{ + "localWeather": map[string]interface{}{ + "temperature": 34, + }, + } + expectedOutput := map[string]interface{}{ + "weather": "hot", + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) +} + +func TestWorkflowRunner_Run_YAML_WithSchemaValidation(t *testing.T) { + // Workflow 1: Workflow input Schema Validation + t.Run("Workflow input Schema Validation - Valid input", func(t *testing.T) { + workflowPath := "./testdata/workflow_input_schema.yaml" + input := map[string]interface{}{ + "key": "value", + } + expectedOutput := map[string]interface{}{ + "outputKey": "value", + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("Workflow input Schema Validation - Invalid input", func(t *testing.T) { + workflowPath := "./testdata/workflow_input_schema.yaml" + input := map[string]interface{}{ + "wrongKey": "value", + } + yamlBytes, err := os.ReadFile(filepath.Clean(workflowPath)) + assert.NoError(t, err, "Failed to read workflow YAML file") + workflow, err := parser.FromYAMLSource(yamlBytes) + assert.NoError(t, err, "Failed to parse workflow YAML") + runner := NewDefaultRunner(workflow) + _, err = runner.Run(input) + assert.Error(t, err, "Expected validation error for invalid input") + assert.Contains(t, err.Error(), "JSON schema validation failed") + }) + + // Workflow 2: Task input Schema Validation + t.Run("Task input Schema Validation", func(t *testing.T) { + workflowPath := "./testdata/task_input_schema.yaml" + input := map[string]interface{}{ + "taskInputKey": 42, + } + expectedOutput := map[string]interface{}{ + "taskOutputKey": 84, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("Task input Schema Validation - Invalid input", func(t *testing.T) { + workflowPath := "./testdata/task_input_schema.yaml" + input := map[string]interface{}{ + "taskInputKey": "invalidValue", + } + yamlBytes, err := os.ReadFile(filepath.Clean(workflowPath)) + assert.NoError(t, err, "Failed to read workflow YAML file") + workflow, err := parser.FromYAMLSource(yamlBytes) + assert.NoError(t, err, "Failed to parse workflow YAML") + runner := NewDefaultRunner(workflow) + _, err = runner.Run(input) + assert.Error(t, err, "Expected validation error for invalid task input") + assert.Contains(t, err.Error(), "JSON schema validation failed") + }) + + // Workflow 3: Task output Schema Validation + t.Run("Task output Schema Validation", func(t *testing.T) { + workflowPath := "./testdata/task_output_schema.yaml" + input := map[string]interface{}{ + "taskInputKey": "value", + } + expectedOutput := map[string]interface{}{ + "finalOutputKey": "resultValue", + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("Task output Schema Validation - Invalid output", func(t *testing.T) { + workflowPath := "./testdata/task_output_schema_with_dynamic_value.yaml" + input := map[string]interface{}{ + "taskInputKey": 123, // Invalid value (not a string) + } + yamlBytes, err := os.ReadFile(filepath.Clean(workflowPath)) + assert.NoError(t, err, "Failed to read workflow YAML file") + workflow, err := parser.FromYAMLSource(yamlBytes) + assert.NoError(t, err, "Failed to parse workflow YAML") + runner := NewDefaultRunner(workflow) + _, err = runner.Run(input) + assert.Error(t, err, "Expected validation error for invalid task output") + assert.Contains(t, err.Error(), "JSON schema validation failed") + }) + + t.Run("Task output Schema Validation - Valid output", func(t *testing.T) { + workflowPath := "./testdata/task_output_schema_with_dynamic_value.yaml" + input := map[string]interface{}{ + "taskInputKey": "validValue", // Valid value + } + expectedOutput := map[string]interface{}{ + "finalOutputKey": "validValue", + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + // Workflow 4: Task Export Schema Validation + t.Run("Task Export Schema Validation", func(t *testing.T) { + workflowPath := "./testdata/task_export_schema.yaml" + input := map[string]interface{}{ + "key": "value", + } + expectedOutput := map[string]interface{}{ + "exportedKey": "value", } runWorkflowTest(t, workflowPath, input, expectedOutput) }) diff --git a/impl/testdata/conditional_logic_input_from.yaml b/impl/testdata/conditional_logic_input_from.yaml new file mode 100644 index 0000000..1b51f04 --- /dev/null +++ b/impl/testdata/conditional_logic_input_from.yaml @@ -0,0 +1,11 @@ +document: + name: conditional-logic + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +input: + from: "${ .localWeather }" +do: + - task2: + set: + weather: "${ if .temperature > 25 then 'hot' else 'cold' end }" diff --git a/impl/testdata/sequential_set_colors.yaml b/impl/testdata/sequential_set_colors.yaml index 73162cc..82274f3 100644 --- a/impl/testdata/sequential_set_colors.yaml +++ b/impl/testdata/sequential_set_colors.yaml @@ -12,4 +12,6 @@ do: colors: ${ .colors + ["green"] } - setBlue: set: - colors: ${ .colors + ["blue"] } \ No newline at end of file + colors: ${ .colors + ["blue"] } + output: + as: "${ { resultColors: .colors } }" \ No newline at end of file diff --git a/impl/testdata/sequential_set_colors_output_as.yaml b/impl/testdata/sequential_set_colors_output_as.yaml new file mode 100644 index 0000000..06e7b24 --- /dev/null +++ b/impl/testdata/sequential_set_colors_output_as.yaml @@ -0,0 +1,17 @@ +document: + dsl: '1.0.0-alpha5' + namespace: default + name: do + version: '1.0.0' +do: + - setRed: + set: + colors: ${ .colors + ["red"] } + - setGreen: + set: + colors: ${ .colors + ["green"] } + - setBlue: + set: + colors: ${ .colors + ["blue"] } +output: + as: "${ { result: .colors } }" \ No newline at end of file diff --git a/impl/testdata/task_export_schema.yaml b/impl/testdata/task_export_schema.yaml new file mode 100644 index 0000000..6148751 --- /dev/null +++ b/impl/testdata/task_export_schema.yaml @@ -0,0 +1,18 @@ +document: + name: task-export-schema + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + exportedKey: "${ .key }" + export: + schema: + format: "json" + document: + type: "object" + properties: + exportedKey: + type: "string" + required: ["exportedKey"] diff --git a/impl/testdata/task_input_schema.yaml b/impl/testdata/task_input_schema.yaml new file mode 100644 index 0000000..898e244 --- /dev/null +++ b/impl/testdata/task_input_schema.yaml @@ -0,0 +1,18 @@ +document: + name: task-input-schema + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + input: + schema: + format: "json" + document: + type: "object" + properties: + taskInputKey: + type: "number" + required: ["taskInputKey"] + set: + taskOutputKey: "${ .taskInputKey * 2 }" diff --git a/impl/testdata/task_output_schema.yaml b/impl/testdata/task_output_schema.yaml new file mode 100644 index 0000000..16c855f --- /dev/null +++ b/impl/testdata/task_output_schema.yaml @@ -0,0 +1,18 @@ +document: + name: task-output-schema + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + finalOutputKey: "resultValue" + output: + schema: + format: "json" + document: + type: "object" + properties: + finalOutputKey: + type: "string" + required: ["finalOutputKey"] diff --git a/impl/testdata/task_output_schema_with_dynamic_value.yaml b/impl/testdata/task_output_schema_with_dynamic_value.yaml new file mode 100644 index 0000000..5efaf91 --- /dev/null +++ b/impl/testdata/task_output_schema_with_dynamic_value.yaml @@ -0,0 +1,18 @@ +document: + name: task-output-schema-with-dynamic-value + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + finalOutputKey: "${ .taskInputKey }" + output: + schema: + format: "json" + document: + type: "object" + properties: + finalOutputKey: + type: "string" + required: ["finalOutputKey"] diff --git a/impl/testdata/workflow_input_schema.yaml b/impl/testdata/workflow_input_schema.yaml new file mode 100644 index 0000000..a7be0d2 --- /dev/null +++ b/impl/testdata/workflow_input_schema.yaml @@ -0,0 +1,18 @@ +document: + name: workflow-input-schema + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +input: + schema: + format: "json" + document: + type: "object" + properties: + key: + type: "string" + required: ["key"] +do: + - task1: + set: + outputKey: "${ .key }" diff --git a/model/endpoint_test.go b/model/endpoint_test.go index 59ddd45..974216e 100644 --- a/model/endpoint_test.go +++ b/model/endpoint_test.go @@ -79,7 +79,7 @@ func TestEndpoint_UnmarshalJSON(t *testing.T) { assert.Error(t, err, "Unmarshal should return an error for invalid JSON structure") }) - t.Run("Empty Input", func(t *testing.T) { + t.Run("Empty input", func(t *testing.T) { input := `{}` var endpoint Endpoint err := json.Unmarshal([]byte(input), &endpoint) @@ -99,7 +99,7 @@ func TestEndpoint_MarshalJSON(t *testing.T) { data, err := json.Marshal(endpoint) assert.NoError(t, err, "Marshal should not return an error") - assert.JSONEq(t, `"${example}"`, string(data), "Output JSON should match") + assert.JSONEq(t, `"${example}"`, string(data), "output JSON should match") }) t.Run("Marshal URITemplate", func(t *testing.T) { @@ -109,7 +109,7 @@ func TestEndpoint_MarshalJSON(t *testing.T) { data, err := json.Marshal(endpoint) assert.NoError(t, err, "Marshal should not return an error") - assert.JSONEq(t, `"http://example.com/{id}"`, string(data), "Output JSON should match") + assert.JSONEq(t, `"http://example.com/{id}"`, string(data), "output JSON should match") }) t.Run("Marshal EndpointConfiguration", func(t *testing.T) { @@ -131,7 +131,7 @@ func TestEndpoint_MarshalJSON(t *testing.T) { "basic": { "username": "john", "password": "secret" } } }` - assert.JSONEq(t, expected, string(data), "Output JSON should match") + assert.JSONEq(t, expected, string(data), "output JSON should match") }) t.Run("Marshal Empty Endpoint", func(t *testing.T) { @@ -139,6 +139,6 @@ func TestEndpoint_MarshalJSON(t *testing.T) { data, err := json.Marshal(endpoint) assert.NoError(t, err, "Marshal should not return an error") - assert.JSONEq(t, `{}`, string(data), "Output JSON should be empty") + assert.JSONEq(t, `{}`, string(data), "output JSON should be empty") }) } diff --git a/model/errors.go b/model/errors.go new file mode 100644 index 0000000..7d50b4a --- /dev/null +++ b/model/errors.go @@ -0,0 +1,300 @@ +package model + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" +) + +// List of Standard Errors based on the Serverless Workflow specification. +// See: https://github.com/serverlessworkflow/specification/blob/main/dsl-reference.md#standard-error-types +const ( + ErrorTypeConfiguration = "https://serverlessworkflow.io/spec/1.0.0/errors/configuration" + ErrorTypeValidation = "https://serverlessworkflow.io/spec/1.0.0/errors/validation" + ErrorTypeExpression = "https://serverlessworkflow.io/spec/1.0.0/errors/expression" + ErrorTypeAuthentication = "https://serverlessworkflow.io/spec/1.0.0/errors/authentication" + ErrorTypeAuthorization = "https://serverlessworkflow.io/spec/1.0.0/errors/authorization" + ErrorTypeTimeout = "https://serverlessworkflow.io/spec/1.0.0/errors/timeout" + ErrorTypeCommunication = "https://serverlessworkflow.io/spec/1.0.0/errors/communication" + ErrorTypeRuntime = "https://serverlessworkflow.io/spec/1.0.0/errors/runtime" +) + +type Error struct { + Type *URITemplateOrRuntimeExpr `json:"type" validate:"required"` + Status int `json:"status" validate:"required"` + Title string `json:"title,omitempty"` + Detail string `json:"detail,omitempty"` + Instance *JsonPointerOrRuntimeExpression `json:"instance,omitempty" validate:"omitempty"` +} + +type ErrorFilter struct { + Type string `json:"type,omitempty"` + Status int `json:"status,omitempty"` + Instance string `json:"instance,omitempty"` + Title string `json:"title,omitempty"` + Details string `json:"details,omitempty"` +} + +func (e *Error) Error() string { + return fmt.Sprintf("[%d] %s: %s (%s). Origin: '%s'", e.Status, e.Title, e.Detail, e.Type, e.Instance) +} + +// WithInstanceRef ensures the error has a valid JSON Pointer reference +func (e *Error) WithInstanceRef(workflow *Workflow, taskName string) *Error { + if e == nil { + return nil + } + + // Check if the instance is already set + if e.Instance.IsValid() { + return e + } + + // Generate a JSON pointer reference for the task within the workflow + instance, pointerErr := GenerateJSONPointer(workflow, taskName) + if pointerErr == nil { + e.Instance = &JsonPointerOrRuntimeExpression{Value: instance} + } + // TODO: log the pointer error + + return e +} + +// newError creates a new structured error +func newError(errType string, status int, title string, detail error, instance string) *Error { + if detail != nil { + return &Error{ + Type: NewUriTemplate(errType), + Status: status, + Title: title, + Detail: detail.Error(), + Instance: &JsonPointerOrRuntimeExpression{ + Value: instance, + }, + } + } + + return &Error{ + Type: NewUriTemplate(errType), + Status: status, + Title: title, + Instance: &JsonPointerOrRuntimeExpression{ + Value: instance, + }, + } +} + +// Convenience Functions for Standard Errors + +func NewErrConfiguration(detail error, instance string) *Error { + return newError( + ErrorTypeConfiguration, + 400, + "Configuration Error", + detail, + instance, + ) +} + +func NewErrValidation(detail error, instance string) *Error { + return newError( + ErrorTypeValidation, + 400, + "Validation Error", + detail, + instance, + ) +} + +func NewErrExpression(detail error, instance string) *Error { + return newError( + ErrorTypeExpression, + 400, + "Expression Error", + detail, + instance, + ) +} + +func NewErrAuthentication(detail error, instance string) *Error { + return newError( + ErrorTypeAuthentication, + 401, + "Authentication Error", + detail, + instance, + ) +} + +func NewErrAuthorization(detail error, instance string) *Error { + return newError( + ErrorTypeAuthorization, + 403, + "Authorization Error", + detail, + instance, + ) +} + +func NewErrTimeout(detail error, instance string) *Error { + return newError( + ErrorTypeTimeout, + 408, + "Timeout Error", + detail, + instance, + ) +} + +func NewErrCommunication(detail error, instance string) *Error { + return newError( + ErrorTypeCommunication, + 500, + "Communication Error", + detail, + instance, + ) +} + +func NewErrRuntime(detail error, instance string) *Error { + return newError( + ErrorTypeRuntime, + 500, + "Runtime Error", + detail, + instance, + ) +} + +// Error Classification Functions + +func IsErrConfiguration(err error) bool { + return isErrorType(err, ErrorTypeConfiguration) +} + +func IsErrValidation(err error) bool { + return isErrorType(err, ErrorTypeValidation) +} + +func IsErrExpression(err error) bool { + return isErrorType(err, ErrorTypeExpression) +} + +func IsErrAuthentication(err error) bool { + return isErrorType(err, ErrorTypeAuthentication) +} + +func IsErrAuthorization(err error) bool { + return isErrorType(err, ErrorTypeAuthorization) +} + +func IsErrTimeout(err error) bool { + return isErrorType(err, ErrorTypeTimeout) +} + +func IsErrCommunication(err error) bool { + return isErrorType(err, ErrorTypeCommunication) +} + +func IsErrRuntime(err error) bool { + return isErrorType(err, ErrorTypeRuntime) +} + +// Helper function to check error type +func isErrorType(err error, errorType string) bool { + var e *Error + if ok := errors.As(err, &e); ok && strings.EqualFold(e.Type.String(), errorType) { + return true + } + return false +} + +// AsError attempts to extract a known error type from the given error. +// If the error is one of the predefined structured errors, it returns the *Error. +// Otherwise, it returns nil. +func AsError(err error) *Error { + var e *Error + if errors.As(err, &e) { + return e // Successfully extracted as a known error type + } + return nil // Not a known error +} + +// Serialization and Deserialization Functions + +func ErrorToJSON(err *Error) (string, error) { + if err == nil { + return "", fmt.Errorf("error is nil") + } + jsonBytes, marshalErr := json.Marshal(err) + if marshalErr != nil { + return "", fmt.Errorf("failed to marshal error: %w", marshalErr) + } + return string(jsonBytes), nil +} + +func ErrorFromJSON(jsonStr string) (*Error, error) { + var errObj Error + if err := json.Unmarshal([]byte(jsonStr), &errObj); err != nil { + return nil, fmt.Errorf("failed to unmarshal error JSON: %w", err) + } + return &errObj, nil +} + +// JsonPointer functions + +func findJsonPointer(data interface{}, target string, path string) (string, bool) { + switch node := data.(type) { + case map[string]interface{}: + for key, value := range node { + newPath := fmt.Sprintf("%s/%s", path, key) + if key == target { + return newPath, true + } + if result, found := findJsonPointer(value, target, newPath); found { + return result, true + } + } + case []interface{}: + for i, item := range node { + newPath := fmt.Sprintf("%s/%d", path, i) + if result, found := findJsonPointer(item, target, newPath); found { + return result, true + } + } + } + return "", false +} + +// GenerateJSONPointer Function to generate JSON Pointer from a Workflow reference +func GenerateJSONPointer(workflow *Workflow, targetNode interface{}) (string, error) { + // Convert struct to JSON + jsonData, err := json.Marshal(workflow) + if err != nil { + return "", fmt.Errorf("error marshalling to JSON: %v", err) + } + + // Convert JSON to a generic map for traversal + var jsonMap map[string]interface{} + if err := json.Unmarshal(jsonData, &jsonMap); err != nil { + return "", fmt.Errorf("error unmarshalling JSON: %v", err) + } + + transformedNode := "" + switch node := targetNode.(type) { + case string: + transformedNode = node + default: + transformedNode = strings.ToLower(reflect.TypeOf(targetNode).Name()) + } + + // Search for the target node + jsonPointer, found := findJsonPointer(jsonMap, transformedNode, "") + if !found { + return "", fmt.Errorf("node '%s' not found", targetNode) + } + + return jsonPointer, nil +} diff --git a/model/errors_test.go b/model/errors_test.go new file mode 100644 index 0000000..6bf26e6 --- /dev/null +++ b/model/errors_test.go @@ -0,0 +1,124 @@ +package model + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +// TestGenerateJSONPointer_SimpleTask tests a simple workflow task. +func TestGenerateJSONPointer_SimpleTask(t *testing.T) { + workflow := &Workflow{ + Document: Document{Name: "simple-workflow"}, + Do: &TaskList{ + &TaskItem{Key: "task1", Task: &SetTask{Set: map[string]interface{}{"value": 10}}}, + &TaskItem{Key: "task2", Task: &SetTask{Set: map[string]interface{}{"double": "${ .value * 2 }"}}}, + }, + } + + jsonPointer, err := GenerateJSONPointer(workflow, "task2") + assert.NoError(t, err) + assert.Equal(t, "/do/1/task2", jsonPointer) +} + +// TestGenerateJSONPointer_SimpleTask tests a simple workflow task. +func TestGenerateJSONPointer_Document(t *testing.T) { + workflow := &Workflow{ + Document: Document{Name: "simple-workflow"}, + Do: &TaskList{ + &TaskItem{Key: "task1", Task: &SetTask{Set: map[string]interface{}{"value": 10}}}, + &TaskItem{Key: "task2", Task: &SetTask{Set: map[string]interface{}{"double": "${ .value * 2 }"}}}, + }, + } + + jsonPointer, err := GenerateJSONPointer(workflow, workflow.Document) + assert.NoError(t, err) + assert.Equal(t, "/document", jsonPointer) +} + +func TestGenerateJSONPointer_ForkTask(t *testing.T) { + workflow := &Workflow{ + Document: Document{Name: "fork-example"}, + Do: &TaskList{ + &TaskItem{ + Key: "raiseAlarm", + Task: &ForkTask{ + Fork: ForkTaskConfiguration{ + Compete: true, + Branches: &TaskList{ + {Key: "callNurse", Task: &CallHTTP{Call: "http", With: HTTPArguments{Method: "put", Endpoint: NewEndpoint("https://hospital.com/api/alert/nurses")}}}, + {Key: "callDoctor", Task: &CallHTTP{Call: "http", With: HTTPArguments{Method: "put", Endpoint: NewEndpoint("https://hospital.com/api/alert/doctor")}}}, + }, + }, + }, + }, + }, + } + + jsonPointer, err := GenerateJSONPointer(workflow, "callDoctor") + assert.NoError(t, err) + assert.Equal(t, "/do/0/raiseAlarm/fork/branches/1/callDoctor", jsonPointer) +} + +// TestGenerateJSONPointer_DeepNestedTask tests multiple nested task levels. +func TestGenerateJSONPointer_DeepNestedTask(t *testing.T) { + workflow := &Workflow{ + Document: Document{Name: "deep-nested"}, + Do: &TaskList{ + &TaskItem{ + Key: "step1", + Task: &ForkTask{ + Fork: ForkTaskConfiguration{ + Compete: false, + Branches: &TaskList{ + { + Key: "branchA", + Task: &ForkTask{ + Fork: ForkTaskConfiguration{ + Branches: &TaskList{ + { + Key: "deepTask", + Task: &SetTask{Set: map[string]interface{}{"result": "done"}}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + jsonPointer, err := GenerateJSONPointer(workflow, "deepTask") + assert.NoError(t, err) + assert.Equal(t, "/do/0/step1/fork/branches/0/branchA/fork/branches/0/deepTask", jsonPointer) +} + +// TestGenerateJSONPointer_NonExistentTask checks for a task that doesn't exist. +func TestGenerateJSONPointer_NonExistentTask(t *testing.T) { + workflow := &Workflow{ + Document: Document{Name: "nonexistent-test"}, + Do: &TaskList{ + &TaskItem{Key: "taskA", Task: &SetTask{Set: map[string]interface{}{"value": 5}}}, + }, + } + + _, err := GenerateJSONPointer(workflow, "taskX") + assert.Error(t, err) +} + +// TestGenerateJSONPointer_MixedTaskTypes verifies a workflow with different task types. +func TestGenerateJSONPointer_MixedTaskTypes(t *testing.T) { + workflow := &Workflow{ + Document: Document{Name: "mixed-tasks"}, + Do: &TaskList{ + &TaskItem{Key: "compute", Task: &SetTask{Set: map[string]interface{}{"result": 42}}}, + &TaskItem{Key: "notify", Task: &CallHTTP{Call: "http", With: HTTPArguments{Method: "post", Endpoint: NewEndpoint("https://api.notify.com")}}}, + }, + } + + jsonPointer, err := GenerateJSONPointer(workflow, "notify") + assert.NoError(t, err) + assert.Equal(t, "/do/1/notify", jsonPointer) +} diff --git a/model/objects.go b/model/objects.go index ecfba00..afbdd48 100644 --- a/model/objects.go +++ b/model/objects.go @@ -53,6 +53,18 @@ type ObjectOrRuntimeExpr struct { Value interface{} `json:"-" validate:"object_or_runtime_expr"` // Custom validation tag. } +func (o *ObjectOrRuntimeExpr) AsStringOrMap() interface{} { + switch o.Value.(type) { + case map[string]interface{}: + return o.Value.(map[string]interface{}) + case string: + return o.Value.(string) + case RuntimeExpression: + return o.Value.(RuntimeExpression).Value + } + return nil +} + // UnmarshalJSON unmarshals data into either a RuntimeExpression or an object. func (o *ObjectOrRuntimeExpr) UnmarshalJSON(data []byte) error { // Attempt to decode as a RuntimeExpression @@ -258,3 +270,18 @@ func (j *JsonPointerOrRuntimeExpression) MarshalJSON() ([]byte, error) { return nil, fmt.Errorf("JsonPointerOrRuntimeExpression contains unsupported type") } } + +func (j *JsonPointerOrRuntimeExpression) String() string { + switch v := j.Value.(type) { + case RuntimeExpression: + return v.String() + case string: + return v + default: + return "" + } +} + +func (j *JsonPointerOrRuntimeExpression) IsValid() bool { + return JSONPointerPattern.MatchString(j.String()) +} diff --git a/model/task.go b/model/task.go index 3bbeb4d..2e57ab1 100644 --- a/model/task.go +++ b/model/task.go @@ -36,33 +36,8 @@ type TaskBase struct { } // Task represents a discrete unit of work in a workflow. -type Task interface{} - -// TaskItem represents a named task and its associated definition. -type TaskItem struct { - Key string `json:"-" validate:"required"` - Task Task `json:"-" validate:"required"` -} - -// MarshalJSON for TaskItem to ensure proper serialization as a key-value pair. -func (ti *TaskItem) MarshalJSON() ([]byte, error) { - if ti == nil { - return nil, fmt.Errorf("cannot marshal a nil TaskItem") - } - - // Serialize the Task - taskJSON, err := json.Marshal(ti.Task) - if err != nil { - return nil, fmt.Errorf("failed to marshal task: %w", err) - } - - // Create a map with the Key and Task - taskEntry := map[string]json.RawMessage{ - ti.Key: taskJSON, - } - - // Marshal the map into JSON - return json.Marshal(taskEntry) +type Task interface { + GetBase() *TaskBase } type NamedTaskMap map[string]Task @@ -194,51 +169,35 @@ func (tl *TaskList) Key(key string) *TaskItem { return nil } -// AsTask extracts the TaskBase from the Task if the Task embeds TaskBase. -// Returns nil if the Task does not embed TaskBase. -func (ti *TaskItem) AsTask() *TaskBase { - if ti == nil || ti.Task == nil { - return nil +// TaskItem represents a named task and its associated definition. +type TaskItem struct { + Key string `json:"-" validate:"required"` + Task Task `json:"-" validate:"required"` +} + +// MarshalJSON for TaskItem to ensure proper serialization as a key-value pair. +func (ti *TaskItem) MarshalJSON() ([]byte, error) { + if ti == nil { + return nil, fmt.Errorf("cannot marshal a nil TaskItem") } - // Use type assertions to check for TaskBase - switch task := ti.Task.(type) { - case *CallHTTP: - return &task.TaskBase - case *CallOpenAPI: - return &task.TaskBase - case *CallGRPC: - return &task.TaskBase - case *CallAsyncAPI: - return &task.TaskBase - case *CallFunction: - return &task.TaskBase - case *DoTask: - return &task.TaskBase - case *ForkTask: - return &task.TaskBase - case *EmitTask: - return &task.TaskBase - case *ForTask: - return &task.TaskBase - case *ListenTask: - return &task.TaskBase - case *RaiseTask: - return &task.TaskBase - case *RunTask: - return &task.TaskBase - case *SetTask: - return &task.TaskBase - case *SwitchTask: - return &task.TaskBase - case *TryTask: - return &task.TaskBase - case *WaitTask: - return &task.TaskBase - default: - // If the type does not embed TaskBase, return nil - return nil + // Serialize the Task + taskJSON, err := json.Marshal(ti.Task) + if err != nil { + return nil, fmt.Errorf("failed to marshal task: %w", err) } + + // Create a map with the Key and Task + taskEntry := map[string]json.RawMessage{ + ti.Key: taskJSON, + } + + // Marshal the map into JSON + return json.Marshal(taskEntry) +} + +func (ti *TaskItem) GetBase() *TaskBase { + return ti.Task.GetBase() } // AsCallHTTPTask casts the Task to a CallTask if possible, returning nil if the cast fails. diff --git a/model/task_call.go b/model/task_call.go index 82412b0..c3e83df 100644 --- a/model/task_call.go +++ b/model/task_call.go @@ -22,6 +22,10 @@ type CallHTTP struct { With HTTPArguments `json:"with" validate:"required"` } +func (c *CallHTTP) GetBase() *TaskBase { + return &c.TaskBase +} + type HTTPArguments struct { Method string `json:"method" validate:"required,oneofci=GET POST PUT DELETE PATCH"` Endpoint *Endpoint `json:"endpoint" validate:"required"` @@ -37,6 +41,10 @@ type CallOpenAPI struct { With OpenAPIArguments `json:"with" validate:"required"` } +func (c *CallOpenAPI) GetBase() *TaskBase { + return &c.TaskBase +} + type OpenAPIArguments struct { Document *ExternalResource `json:"document" validate:"required"` OperationID string `json:"operationId" validate:"required"` @@ -51,6 +59,10 @@ type CallGRPC struct { With GRPCArguments `json:"with" validate:"required"` } +func (c *CallGRPC) GetBase() *TaskBase { + return &c.TaskBase +} + type GRPCArguments struct { Proto *ExternalResource `json:"proto" validate:"required"` Service GRPCService `json:"service" validate:"required"` @@ -72,6 +84,10 @@ type CallAsyncAPI struct { With AsyncAPIArguments `json:"with" validate:"required"` } +func (c *CallAsyncAPI) GetBase() *TaskBase { + return &c.TaskBase +} + type AsyncAPIArguments struct { Document *ExternalResource `json:"document" validate:"required"` Channel string `json:"channel,omitempty"` @@ -110,3 +126,7 @@ type CallFunction struct { Call string `json:"call" validate:"required"` With map[string]interface{} `json:"with,omitempty"` } + +func (c *CallFunction) GetBase() *TaskBase { + return &c.TaskBase +} diff --git a/model/task_do.go b/model/task_do.go index 0b2673d..f1dca25 100644 --- a/model/task_do.go +++ b/model/task_do.go @@ -19,3 +19,7 @@ type DoTask struct { TaskBase `json:",inline"` // Inline TaskBase fields Do *TaskList `json:"do" validate:"required,dive"` } + +func (d *DoTask) GetBase() *TaskBase { + return &d.TaskBase +} diff --git a/model/task_event.go b/model/task_event.go index 8b97388..5df1ab6 100644 --- a/model/task_event.go +++ b/model/task_event.go @@ -26,6 +26,10 @@ type EmitTask struct { Emit EmitTaskConfiguration `json:"emit" validate:"required"` } +func (e *EmitTask) GetBase() *TaskBase { + return &e.TaskBase +} + func (e *EmitTask) MarshalJSON() ([]byte, error) { type Alias EmitTask // Prevent recursion return json.Marshal((*Alias)(e)) @@ -37,6 +41,10 @@ type ListenTask struct { Listen ListenTaskConfiguration `json:"listen" validate:"required"` } +func (lt *ListenTask) GetBase() *TaskBase { + return <.TaskBase +} + type ListenTaskConfiguration struct { To *EventConsumptionStrategy `json:"to" validate:"required"` } diff --git a/model/task_for.go b/model/task_for.go index 0e6811b..5fc84ec 100644 --- a/model/task_for.go +++ b/model/task_for.go @@ -22,6 +22,10 @@ type ForTask struct { Do *TaskList `json:"do" validate:"required,dive"` } +func (f *ForTask) GetBase() *TaskBase { + return &f.TaskBase +} + // ForTaskConfiguration defines the loop configuration for iterating over a collection. type ForTaskConfiguration struct { Each string `json:"each,omitempty"` // Variable name for the current item diff --git a/model/task_fork.go b/model/task_fork.go index 3019d06..1511729 100644 --- a/model/task_fork.go +++ b/model/task_fork.go @@ -20,6 +20,10 @@ type ForkTask struct { Fork ForkTaskConfiguration `json:"fork" validate:"required"` } +func (f *ForkTask) GetBase() *TaskBase { + return &f.TaskBase +} + // ForkTaskConfiguration defines the configuration for the branches to perform concurrently. type ForkTaskConfiguration struct { Branches *TaskList `json:"branches" validate:"required,dive"` diff --git a/model/task_raise.go b/model/task_raise.go index b0c7499..5dafd55 100644 --- a/model/task_raise.go +++ b/model/task_raise.go @@ -19,28 +19,16 @@ import ( "errors" ) -type Error struct { - Type *URITemplateOrRuntimeExpr `json:"type" validate:"required"` - Status int `json:"status" validate:"required"` - Title string `json:"title,omitempty"` - Detail string `json:"detail,omitempty"` - Instance *JsonPointerOrRuntimeExpression `json:"instance,omitempty" validate:"omitempty"` -} - -type ErrorFilter struct { - Type string `json:"type,omitempty"` - Status int `json:"status,omitempty"` - Instance string `json:"instance,omitempty"` - Title string `json:"title,omitempty"` - Details string `json:"details,omitempty"` -} - // RaiseTask represents a task configuration to raise errors. type RaiseTask struct { TaskBase `json:",inline"` // Inline TaskBase fields Raise RaiseTaskConfiguration `json:"raise" validate:"required"` } +func (r *RaiseTask) GetBase() *TaskBase { + return &r.TaskBase +} + type RaiseTaskConfiguration struct { Error RaiseTaskError `json:"error" validate:"required"` } diff --git a/model/task_run.go b/model/task_run.go index 6942013..b589cfa 100644 --- a/model/task_run.go +++ b/model/task_run.go @@ -25,6 +25,10 @@ type RunTask struct { Run RunTaskConfiguration `json:"run" validate:"required"` } +func (r *RunTask) GetBase() *TaskBase { + return &r.TaskBase +} + type RunTaskConfiguration struct { Await *bool `json:"await,omitempty"` Container *Container `json:"container,omitempty"` diff --git a/model/task_set.go b/model/task_set.go index 654c48f..68816ba 100644 --- a/model/task_set.go +++ b/model/task_set.go @@ -22,6 +22,10 @@ type SetTask struct { Set map[string]interface{} `json:"set" validate:"required,min=1,dive"` } +func (st *SetTask) GetBase() *TaskBase { + return &st.TaskBase +} + // MarshalJSON for SetTask to ensure proper serialization. func (st *SetTask) MarshalJSON() ([]byte, error) { type Alias SetTask diff --git a/model/task_switch.go b/model/task_switch.go index d63b2e7..89ca9c1 100644 --- a/model/task_switch.go +++ b/model/task_switch.go @@ -22,6 +22,10 @@ type SwitchTask struct { Switch []SwitchItem `json:"switch" validate:"required,min=1,dive,switch_item"` } +func (st *SwitchTask) GetBase() *TaskBase { + return &st.TaskBase +} + type SwitchItem map[string]SwitchCase // SwitchCase defines a condition and the corresponding outcome for a switch task. diff --git a/model/task_try.go b/model/task_try.go index 91d3797..57ba9df 100644 --- a/model/task_try.go +++ b/model/task_try.go @@ -26,6 +26,10 @@ type TryTask struct { Catch *TryTaskCatch `json:"catch" validate:"required"` } +func (t *TryTask) GetBase() *TaskBase { + return &t.TaskBase +} + type TryTaskCatch struct { Errors struct { With *ErrorFilter `json:"with,omitempty"` diff --git a/model/task_wait.go b/model/task_wait.go index 41b5cc5..e312824 100644 --- a/model/task_wait.go +++ b/model/task_wait.go @@ -25,6 +25,10 @@ type WaitTask struct { Wait *Duration `json:"wait" validate:"required"` } +func (wt *WaitTask) GetBase() *TaskBase { + return &wt.TaskBase +} + // MarshalJSON for WaitTask to ensure proper serialization. func (wt *WaitTask) MarshalJSON() ([]byte, error) { type Alias WaitTask diff --git a/model/workflow_test.go b/model/workflow_test.go index df90f1e..4a620af 100644 --- a/model/workflow_test.go +++ b/model/workflow_test.go @@ -283,7 +283,7 @@ type InputTestCase struct { func TestInputValidation(t *testing.T) { cases := []InputTestCase{ { - Name: "Valid Input with Schema and From (object)", + Name: "Valid input with Schema and From (object)", Input: Input{ Schema: &Schema{ Format: "json", @@ -301,7 +301,7 @@ func TestInputValidation(t *testing.T) { ShouldErr: false, }, { - Name: "Invalid Input with Schema and From (expr)", + Name: "Invalid input with Schema and From (expr)", Input: Input{ Schema: &Schema{ Format: "json", @@ -313,7 +313,7 @@ func TestInputValidation(t *testing.T) { ShouldErr: true, }, { - Name: "Valid Input with Schema and From (expr)", + Name: "Valid input with Schema and From (expr)", Input: Input{ Schema: &Schema{ Format: "json", @@ -325,7 +325,7 @@ func TestInputValidation(t *testing.T) { ShouldErr: true, }, { - Name: "Invalid Input with Empty From (expr)", + Name: "Invalid input with Empty From (expr)", Input: Input{ From: &ObjectOrRuntimeExpr{ Value: "", @@ -334,7 +334,7 @@ func TestInputValidation(t *testing.T) { ShouldErr: true, }, { - Name: "Invalid Input with Empty From (object)", + Name: "Invalid input with Empty From (object)", Input: Input{ From: &ObjectOrRuntimeExpr{ Value: map[string]interface{}{}, @@ -343,7 +343,7 @@ func TestInputValidation(t *testing.T) { ShouldErr: true, }, { - Name: "Invalid Input with Unsupported From Type", + Name: "Invalid input with Unsupported From Type", Input: Input{ From: &ObjectOrRuntimeExpr{ Value: 123, @@ -352,7 +352,7 @@ func TestInputValidation(t *testing.T) { ShouldErr: true, }, { - Name: "Valid Input with Schema Only", + Name: "Valid input with Schema Only", Input: Input{ Schema: &Schema{ Format: "json", @@ -361,7 +361,7 @@ func TestInputValidation(t *testing.T) { ShouldErr: false, }, { - Name: "Input with Neither Schema Nor From", + Name: "input with Neither Schema Nor From", Input: Input{}, ShouldErr: false, }, From 85ffc5eca7d112deb2d188da53c7f6c407ba26f9 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Wed, 12 Feb 2025 15:46:11 -0500 Subject: [PATCH 03/11] Add raise task Signed-off-by: Ricardo Zanini --- expr/expr.go | 6 +- impl/runner.go | 80 +++++++--- impl/task.go | 63 ++++++++ impl/task_raise_test.go | 142 ++++++++++++++++++ impl/task_test.go | 106 ++++++++++++- impl/testdata/raise_conditional.yaml | 18 +++ impl/testdata/raise_error_with_input.yaml | 13 ++ impl/testdata/raise_inline.yaml | 13 ++ impl/testdata/raise_reusable.yaml | 16 ++ impl/testdata/raise_undefined_reference.yaml | 9 ++ impl/testdata/set_tasks_invalid_then.yaml | 13 ++ impl/testdata/set_tasks_with_termination.yaml | 13 ++ impl/testdata/set_tasks_with_then.yaml | 16 ++ model/endpoint.go | 9 ++ model/errors.go | 24 ++- model/objects.go | 52 +++++++ model/runtime_expression.go | 4 + model/task.go | 34 ++++- model/task_test.go | 67 +++++++++ model/workflow.go | 5 + 20 files changed, 661 insertions(+), 42 deletions(-) create mode 100644 impl/task_raise_test.go create mode 100644 impl/testdata/raise_conditional.yaml create mode 100644 impl/testdata/raise_error_with_input.yaml create mode 100644 impl/testdata/raise_inline.yaml create mode 100644 impl/testdata/raise_reusable.yaml create mode 100644 impl/testdata/raise_undefined_reference.yaml create mode 100644 impl/testdata/set_tasks_invalid_then.yaml create mode 100644 impl/testdata/set_tasks_with_termination.yaml create mode 100644 impl/testdata/set_tasks_with_then.yaml diff --git a/expr/expr.go b/expr/expr.go index d8f2d31..711d899 100644 --- a/expr/expr.go +++ b/expr/expr.go @@ -61,7 +61,7 @@ func TraverseAndEvaluate(node interface{}, input interface{}) (interface{}, erro case string: // Check if the string is a runtime expression (e.g., ${ .some.path }) if IsStrictExpr(v) { - return EvaluateJQExpression(Sanitize(v), input) + return evaluateJQExpression(Sanitize(v), input) } return v, nil @@ -71,8 +71,8 @@ func TraverseAndEvaluate(node interface{}, input interface{}) (interface{}, erro } } -// EvaluateJQExpression evaluates a jq expression against a given JSON input -func EvaluateJQExpression(expression string, input interface{}) (interface{}, error) { +// evaluateJQExpression evaluates a jq expression against a given JSON input +func evaluateJQExpression(expression string, input interface{}) (interface{}, error) { // Parse the sanitized jq expression query, err := gojq.Parse(expression) if err != nil { diff --git a/impl/runner.go b/impl/runner.go index e563efa..32d0cbf 100644 --- a/impl/runner.go +++ b/impl/runner.go @@ -51,7 +51,7 @@ func (wr *workflowRunnerImpl) Run(input interface{}) (output interface{}, err er }() // Process input - if input, err = wr.processInput(input); err != nil { + if input, err = wr.processWorkflowInput(input); err != nil { return nil, err } @@ -64,7 +64,7 @@ func (wr *workflowRunnerImpl) Run(input interface{}) (output interface{}, err er output = wr.RunnerCtx.GetOutput() // Process output - if output, err = wr.processOutput(output); err != nil { + if output, err = wr.processWorkflowOutput(output); err != nil { return nil, err } @@ -72,8 +72,16 @@ func (wr *workflowRunnerImpl) Run(input interface{}) (output interface{}, err er return output, nil } -// processInput validates and transforms input if needed. -func (wr *workflowRunnerImpl) processInput(input interface{}) (interface{}, error) { +// wrapWorkflowError ensures workflow errors have a proper instance reference. +func (wr *workflowRunnerImpl) wrapWorkflowError(err error, taskName string) error { + if knownErr := model.AsError(err); knownErr != nil { + return knownErr.WithInstanceRef(wr.Workflow, taskName) + } + return model.NewErrRuntime(err, taskName) +} + +// processWorkflowInput validates and transforms input if needed. +func (wr *workflowRunnerImpl) processWorkflowInput(input interface{}) (interface{}, error) { if wr.Workflow.Input != nil { var err error if err = validateSchema(input, wr.Workflow.Input.Schema, "/"); err != nil { @@ -99,31 +107,37 @@ func (wr *workflowRunnerImpl) executeTasks(tasks *model.TaskList) error { return nil } - // TODO: implement control flow: continue, end, then - for _, taskItem := range *tasks { + idx := 0 + currentTask := (*tasks)[idx] + + for currentTask != nil { wr.RunnerCtx.SetInput(wr.RunnerCtx.GetOutput()) - if shouldRun, err := wr.shouldRunTask(taskItem); err != nil { + if shouldRun, err := wr.shouldRunTask(currentTask); err != nil { return err } else if !shouldRun { wr.RunnerCtx.SetOutput(wr.RunnerCtx.GetInput()) + idx, currentTask = tasks.Next(idx) continue } - wr.RunnerCtx.SetTaskStatus(taskItem.Key, PendingStatus) - runner, err := NewTaskRunner(taskItem.Key, taskItem.Task) + wr.RunnerCtx.SetTaskStatus(currentTask.Key, PendingStatus) + runner, err := NewTaskRunner(currentTask.Key, currentTask.Task, wr) if err != nil { return err } - wr.RunnerCtx.SetTaskStatus(taskItem.Key, RunningStatus) + wr.RunnerCtx.SetTaskStatus(currentTask.Key, RunningStatus) var output interface{} - if output, err = wr.runTask(runner, taskItem.Task.GetBase()); err != nil { - wr.RunnerCtx.SetTaskStatus(taskItem.Key, FaultedStatus) + if output, err = wr.runTask(runner, currentTask.Task.GetBase()); err != nil { + wr.RunnerCtx.SetTaskStatus(currentTask.Key, FaultedStatus) return err } + // TODO: make sure that `output` is a map[string]interface{}, so compatible to JSON traversal. - wr.RunnerCtx.SetTaskStatus(taskItem.Key, CompletedStatus) + wr.RunnerCtx.SetTaskStatus(currentTask.Key, CompletedStatus) wr.RunnerCtx.SetOutput(output) + + idx, currentTask = tasks.Next(idx) } return nil @@ -131,7 +145,7 @@ func (wr *workflowRunnerImpl) executeTasks(tasks *model.TaskList) error { func (wr *workflowRunnerImpl) shouldRunTask(task *model.TaskItem) (bool, error) { if task.GetBase().If != nil { - output, err := expr.EvaluateJQExpression(task.GetBase().If.String(), wr.RunnerCtx.GetInput()) + output, err := expr.TraverseAndEvaluate(task.GetBase().If.String(), wr.RunnerCtx.GetInput()) if err != nil { return false, model.NewErrExpression(err, task.Key) } @@ -142,8 +156,8 @@ func (wr *workflowRunnerImpl) shouldRunTask(task *model.TaskItem) (bool, error) return true, nil } -// processOutput applies output transformations. -func (wr *workflowRunnerImpl) processOutput(output interface{}) (interface{}, error) { +// processWorkflowOutput applies output transformations. +func (wr *workflowRunnerImpl) processWorkflowOutput(output interface{}) (interface{}, error) { if wr.Workflow.Output != nil { var err error if output, err = traverseAndEvaluate(wr.Workflow.Output.As, wr.RunnerCtx.GetOutput(), "/"); err != nil { @@ -161,16 +175,40 @@ func (wr *workflowRunnerImpl) processOutput(output interface{}) (interface{}, er // ----------------- Task funcs ------------------- // +// TODO: refactor to receive a resolver handler instead of the workflow runner + // NewTaskRunner creates a TaskRunner instance based on the task type. -func NewTaskRunner(taskName string, task model.Task) (TaskRunner, error) { +func NewTaskRunner(taskName string, task model.Task, wr *workflowRunnerImpl) (TaskRunner, error) { switch t := task.(type) { case *model.SetTask: return NewSetTaskRunner(taskName, t) + case *model.RaiseTask: + if err := wr.resolveErrorDefinition(t); err != nil { + return nil, err + } + return NewRaiseTaskRunner(taskName, t) default: return nil, fmt.Errorf("unsupported task type '%T' for task '%s'", t, taskName) } } +// TODO: can e refactored to a definition resolver callable from the context +func (wr *workflowRunnerImpl) resolveErrorDefinition(t *model.RaiseTask) error { + if t.Raise.Error.Ref != nil { + notFoundErr := model.NewErrValidation(fmt.Errorf("%v error definition not found in 'uses'", t.Raise.Error.Ref), "") + if wr.Workflow.Use != nil && wr.Workflow.Use.Errors != nil { + definition, ok := wr.Workflow.Use.Errors[*t.Raise.Error.Ref] + if !ok { + return notFoundErr + } + t.Raise.Error.Definition = definition + return nil + } + return notFoundErr + } + return nil +} + // runTask executes an individual task. func (wr *workflowRunnerImpl) runTask(runner TaskRunner, task *model.TaskBase) (output interface{}, err error) { taskInput := wr.RunnerCtx.GetInput() @@ -234,14 +272,6 @@ func (wr *workflowRunnerImpl) validateAndEvaluateTaskOutput(task *model.TaskBase return output, nil } -// wrapWorkflowError ensures workflow errors have a proper instance reference. -func (wr *workflowRunnerImpl) wrapWorkflowError(err error, taskName string) error { - if knownErr := model.AsError(err); knownErr != nil { - return knownErr.WithInstanceRef(wr.Workflow, taskName) - } - return model.NewErrRuntime(err, taskName) -} - func validateSchema(data interface{}, schema *model.Schema, taskName string) error { if schema != nil { if err := ValidateJSONSchema(data, schema); err != nil { diff --git a/impl/task.go b/impl/task.go index 5f31046..0e01293 100644 --- a/impl/task.go +++ b/impl/task.go @@ -7,6 +7,7 @@ import ( ) var _ TaskRunner = &SetTaskRunner{} +var _ TaskRunner = &RaiseTaskRunner{} type TaskRunner interface { Run(input interface{}) (interface{}, error) @@ -50,3 +51,65 @@ func (s *SetTaskRunner) Run(input interface{}) (output interface{}, err error) { return output, nil } + +func NewRaiseTaskRunner(taskName string, task *model.RaiseTask) (*RaiseTaskRunner, error) { + if task == nil || task.Raise.Error.Definition == nil { + return nil, model.NewErrValidation(fmt.Errorf("no raise configuration provided for RaiseTask %s", taskName), taskName) + } + return &RaiseTaskRunner{ + Task: task, + TaskName: taskName, + }, nil +} + +type RaiseTaskRunner struct { + Task *model.RaiseTask + TaskName string +} + +var raiseErrFuncMapping = map[string]func(error, string) *model.Error{ + model.ErrorTypeAuthentication: model.NewErrAuthentication, + model.ErrorTypeValidation: model.NewErrValidation, + model.ErrorTypeCommunication: model.NewErrCommunication, + model.ErrorTypeAuthorization: model.NewErrAuthorization, + model.ErrorTypeConfiguration: model.NewErrConfiguration, + model.ErrorTypeExpression: model.NewErrExpression, + model.ErrorTypeRuntime: model.NewErrRuntime, + model.ErrorTypeTimeout: model.NewErrTimeout, +} + +func (r *RaiseTaskRunner) Run(input interface{}) (output interface{}, err error) { + output = input + // TODO: make this an external func so we can call it after getting the reference? Or we can get the reference from the workflow definition + var detailResult interface{} + detailResult, err = traverseAndEvaluate(r.Task.Raise.Error.Definition.Detail.AsObjectOrRuntimeExpr(), input, r.TaskName) + if err != nil { + return nil, err + } + + var titleResult interface{} + titleResult, err = traverseAndEvaluate(r.Task.Raise.Error.Definition.Title.AsObjectOrRuntimeExpr(), input, r.TaskName) + if err != nil { + return nil, err + } + + instance := &model.JsonPointerOrRuntimeExpression{Value: r.TaskName} + + var raiseErr *model.Error + if raiseErrF, ok := raiseErrFuncMapping[r.Task.Raise.Error.Definition.Type.String()]; ok { + raiseErr = raiseErrF(fmt.Errorf("%v", detailResult), instance.String()) + } else { + raiseErr = r.Task.Raise.Error.Definition + raiseErr.Detail = model.NewStringOrRuntimeExpr(fmt.Sprintf("%v", detailResult)) + raiseErr.Instance = instance + } + + raiseErr.Title = model.NewStringOrRuntimeExpr(fmt.Sprintf("%v", titleResult)) + err = raiseErr + + return output, err +} + +func (r *RaiseTaskRunner) GetTaskName() string { + return r.TaskName +} diff --git a/impl/task_raise_test.go b/impl/task_raise_test.go new file mode 100644 index 0000000..8733301 --- /dev/null +++ b/impl/task_raise_test.go @@ -0,0 +1,142 @@ +package impl + +import ( + "encoding/json" + "errors" + "github.com/serverlessworkflow/sdk-go/v3/model" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestRaiseTaskRunner_WithDefinedError(t *testing.T) { + input := map[string]interface{}{} + + raiseTask := &model.RaiseTask{ + Raise: model.RaiseTaskConfiguration{ + Error: model.RaiseTaskError{ + Definition: &model.Error{ + Type: model.NewUriTemplate(model.ErrorTypeValidation), + Status: 400, + Title: model.NewStringOrRuntimeExpr("Validation Error"), + Detail: model.NewStringOrRuntimeExpr("Invalid input data"), + }, + }, + }, + } + + runner, err := NewRaiseTaskRunner("task_raise_defined", raiseTask) + assert.NoError(t, err) + + output, err := runner.Run(input) + assert.Equal(t, output, input) + assert.Error(t, err) + + expectedErr := model.NewErrValidation(errors.New("Invalid input data"), "task_raise_defined") + + assert.Equal(t, expectedErr.Type.String(), err.(*model.Error).Type.String()) + assert.Equal(t, expectedErr.Status, err.(*model.Error).Status) + assert.Equal(t, expectedErr.Title.String(), err.(*model.Error).Title.String()) + assert.Equal(t, "Invalid input data", err.(*model.Error).Detail.String()) + assert.Equal(t, expectedErr.Instance.String(), err.(*model.Error).Instance.String()) +} + +func TestRaiseTaskRunner_WithReferencedError(t *testing.T) { + var ref string + ref = "someErrorRef" + raiseTask := &model.RaiseTask{ + Raise: model.RaiseTaskConfiguration{ + Error: model.RaiseTaskError{ + Ref: &ref, + }, + }, + } + + runner, err := NewRaiseTaskRunner("task_raise_ref", raiseTask) + assert.Error(t, err) + assert.Nil(t, runner) +} + +func TestRaiseTaskRunner_TimeoutErrorWithExpression(t *testing.T) { + input := map[string]interface{}{ + "timeoutMessage": "Request took too long", + } + + raiseTask := &model.RaiseTask{ + Raise: model.RaiseTaskConfiguration{ + Error: model.RaiseTaskError{ + Definition: &model.Error{ + Type: model.NewUriTemplate(model.ErrorTypeTimeout), + Status: 408, + Title: model.NewStringOrRuntimeExpr("Timeout Error"), + Detail: model.NewStringOrRuntimeExpr("${ .timeoutMessage }"), + }, + }, + }, + } + + runner, err := NewRaiseTaskRunner("task_raise_timeout_expr", raiseTask) + assert.NoError(t, err) + + output, err := runner.Run(input) + assert.Equal(t, input, output) + assert.Error(t, err) + + expectedErr := model.NewErrTimeout(errors.New("Request took too long"), "task_raise_timeout_expr") + + assert.Equal(t, expectedErr.Type.String(), err.(*model.Error).Type.String()) + assert.Equal(t, expectedErr.Status, err.(*model.Error).Status) + assert.Equal(t, expectedErr.Title.String(), err.(*model.Error).Title.String()) + assert.Equal(t, "Request took too long", err.(*model.Error).Detail.String()) + assert.Equal(t, expectedErr.Instance.String(), err.(*model.Error).Instance.String()) +} + +func TestRaiseTaskRunner_Serialization(t *testing.T) { + raiseTask := &model.RaiseTask{ + Raise: model.RaiseTaskConfiguration{ + Error: model.RaiseTaskError{ + Definition: &model.Error{ + Type: model.NewUriTemplate(model.ErrorTypeRuntime), + Status: 500, + Title: model.NewStringOrRuntimeExpr("Runtime Error"), + Detail: model.NewStringOrRuntimeExpr("Unexpected failure"), + Instance: &model.JsonPointerOrRuntimeExpression{Value: "/task_runtime"}, + }, + }, + }, + } + + data, err := json.Marshal(raiseTask) + assert.NoError(t, err) + + var deserializedTask model.RaiseTask + err = json.Unmarshal(data, &deserializedTask) + assert.NoError(t, err) + + assert.Equal(t, raiseTask.Raise.Error.Definition.Type.String(), deserializedTask.Raise.Error.Definition.Type.String()) + assert.Equal(t, raiseTask.Raise.Error.Definition.Status, deserializedTask.Raise.Error.Definition.Status) + assert.Equal(t, raiseTask.Raise.Error.Definition.Title.String(), deserializedTask.Raise.Error.Definition.Title.String()) + assert.Equal(t, raiseTask.Raise.Error.Definition.Detail.String(), deserializedTask.Raise.Error.Definition.Detail.String()) + assert.Equal(t, raiseTask.Raise.Error.Definition.Instance.String(), deserializedTask.Raise.Error.Definition.Instance.String()) +} + +func TestRaiseTaskRunner_ReferenceSerialization(t *testing.T) { + var ref string + ref = "errorReference" + raiseTask := &model.RaiseTask{ + Raise: model.RaiseTaskConfiguration{ + Error: model.RaiseTaskError{ + Ref: &ref, + }, + }, + } + + data, err := json.Marshal(raiseTask) + assert.NoError(t, err) + + var deserializedTask model.RaiseTask + err = json.Unmarshal(data, &deserializedTask) + assert.NoError(t, err) + + assert.Equal(t, *raiseTask.Raise.Error.Ref, *deserializedTask.Raise.Error.Ref) + assert.Nil(t, deserializedTask.Raise.Error.Definition) +} diff --git a/impl/task_test.go b/impl/task_test.go index 3551c01..b6da3bf 100644 --- a/impl/task_test.go +++ b/impl/task_test.go @@ -1,6 +1,7 @@ package impl import ( + "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/serverlessworkflow/sdk-go/v3/parser" "github.com/stretchr/testify/assert" "os" @@ -10,6 +11,21 @@ import ( // runWorkflowTest is a reusable test function for workflows func runWorkflowTest(t *testing.T, workflowPath string, input, expectedOutput map[string]interface{}) { + // Run the workflow + output, err := runWorkflow(t, workflowPath, input, expectedOutput) + assert.NoError(t, err) + + assertWorkflowRun(t, expectedOutput, output) +} + +func runWorkflowWithErr(t *testing.T, workflowPath string, input, expectedOutput map[string]interface{}, assertErr func(error)) { + output, err := runWorkflow(t, workflowPath, input, expectedOutput) + assert.Error(t, err) + assertErr(err) + assertWorkflowRun(t, expectedOutput, output) +} + +func runWorkflow(t *testing.T, workflowPath string, input, expectedOutput map[string]interface{}) (output interface{}, err error) { // Read the workflow YAML from the testdata directory yamlBytes, err := os.ReadFile(filepath.Clean(workflowPath)) assert.NoError(t, err, "Failed to read workflow YAML file") @@ -22,11 +38,16 @@ func runWorkflowTest(t *testing.T, workflowPath string, input, expectedOutput ma runner := NewDefaultRunner(workflow) // Run the workflow - output, err := runner.Run(input) + output, err = runner.Run(input) + return output, err +} - // Assertions - assert.NoError(t, err) - assert.Equal(t, expectedOutput, output, "Workflow output mismatch") +func assertWorkflowRun(t *testing.T, expectedOutput map[string]interface{}, output interface{}) { + if expectedOutput == nil { + assert.Nil(t, output, "Expected nil Workflow run output") + } else { + assert.Equal(t, expectedOutput, output, "Workflow output mismatch") + } } // TestWorkflowRunner_Run_YAML validates multiple workflows @@ -198,3 +219,80 @@ func TestWorkflowRunner_Run_YAML_WithSchemaValidation(t *testing.T) { runWorkflowTest(t, workflowPath, input, expectedOutput) }) } + +func TestWorkflowRunner_Run_YAML_ControlFlow(t *testing.T) { + t.Run("Set Tasks with Then Directive", func(t *testing.T) { + workflowPath := "./testdata/set_tasks_with_then.yaml" + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "result": float64(90), + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("Set Tasks with Termination", func(t *testing.T) { + workflowPath := "./testdata/set_tasks_with_termination.yaml" + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "finalValue": float64(20), + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("Set Tasks with Invalid Then Reference", func(t *testing.T) { + workflowPath := "./testdata/set_tasks_invalid_then.yaml" + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "partialResult": float64(15), + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) +} + +func TestWorkflowRunner_Run_YAML_RaiseTasks(t *testing.T) { + // TODO: add $workflow context to the expr processing + //t.Run("Raise Inline Error", func(t *testing.T) { + // runWorkflowTest(t, "./testdata/raise_inline.yaml", nil, nil) + //}) + + t.Run("Raise Referenced Error", func(t *testing.T) { + runWorkflowWithErr(t, "./testdata/raise_reusable.yaml", nil, nil, + func(err error) { + assert.Equal(t, model.ErrorTypeAuthentication, model.AsError(err).Type.String()) + }) + }) + + t.Run("Raise Error with Dynamic Detail", func(t *testing.T) { + input := map[string]interface{}{ + "reason": "User token expired", + } + runWorkflowWithErr(t, "./testdata/raise_error_with_input.yaml", input, nil, + func(err error) { + assert.Equal(t, model.ErrorTypeAuthentication, model.AsError(err).Type.String()) + assert.Equal(t, "User authentication failed: User token expired", model.AsError(err).Detail.String()) + }) + }) + + t.Run("Raise Undefined Error Reference", func(t *testing.T) { + runWorkflowWithErr(t, "./testdata/raise_undefined_reference.yaml", nil, nil, + func(err error) { + assert.Equal(t, model.ErrorTypeValidation, model.AsError(err).Type.String()) + }) + }) +} + +func TestWorkflowRunner_Run_YAML_RaiseTasks_ControlFlow(t *testing.T) { + t.Run("Raise Error with Conditional Logic", func(t *testing.T) { + input := map[string]interface{}{ + "user": map[string]interface{}{ + "age": 16, + }, + } + runWorkflowWithErr(t, "./testdata/raise_conditional.yaml", input, nil, + func(err error) { + assert.Equal(t, model.ErrorTypeAuthorization, model.AsError(err).Type.String()) + assert.Equal(t, "User is under the required age", model.AsError(err).Detail.String()) + }) + }) + +} diff --git a/impl/testdata/raise_conditional.yaml b/impl/testdata/raise_conditional.yaml new file mode 100644 index 0000000..9a74d70 --- /dev/null +++ b/impl/testdata/raise_conditional.yaml @@ -0,0 +1,18 @@ +# $schema: https://raw.githubusercontent.com/serverlessworkflow/specification/refs/heads/main/schema/workflow.yaml +document: + dsl: '1.0.0-alpha5' + namespace: test + name: raise-conditional + version: '1.0.0' +do: + - underageError: + if: ${ .user.age < 18 } + raise: + error: + type: https://serverlessworkflow.io/spec/1.0.0/errors/authorization + status: 403 + title: Authorization Error + detail: "User is under the required age" + - continueProcess: + set: + message: "User is allowed" diff --git a/impl/testdata/raise_error_with_input.yaml b/impl/testdata/raise_error_with_input.yaml new file mode 100644 index 0000000..9fe44cd --- /dev/null +++ b/impl/testdata/raise_error_with_input.yaml @@ -0,0 +1,13 @@ +document: + dsl: '1.0.0-alpha5' + namespace: test + name: raise-with-input + version: '1.0.0' +do: + - dynamicError: + raise: + error: + type: https://serverlessworkflow.io/spec/1.0.0/errors/authentication + status: 401 + title: Authentication Error + detail: '${ "User authentication failed: \( .reason )" }' diff --git a/impl/testdata/raise_inline.yaml b/impl/testdata/raise_inline.yaml new file mode 100644 index 0000000..639a778 --- /dev/null +++ b/impl/testdata/raise_inline.yaml @@ -0,0 +1,13 @@ +document: + dsl: '1.0.0-alpha5' + namespace: test + name: raise-inline + version: '1.0.0' +do: + - inlineError: + raise: + error: + type: https://serverlessworkflow.io/spec/1.0.0/errors/validation + status: 400 + title: Validation Error + detail: ${ "Invalid input provided to workflow '\( $workflow.definition.document.name )'" } diff --git a/impl/testdata/raise_reusable.yaml b/impl/testdata/raise_reusable.yaml new file mode 100644 index 0000000..c4858ca --- /dev/null +++ b/impl/testdata/raise_reusable.yaml @@ -0,0 +1,16 @@ +document: + dsl: '1.0.0-alpha5' + namespace: test + name: raise-reusable + version: '1.0.0' +use: + errors: + AuthenticationError: + type: https://serverlessworkflow.io/spec/1.0.0/errors/authentication + status: 401 + title: Authentication Error + detail: "User is not authenticated" +do: + - authError: + raise: + error: AuthenticationError diff --git a/impl/testdata/raise_undefined_reference.yaml b/impl/testdata/raise_undefined_reference.yaml new file mode 100644 index 0000000..7d135d2 --- /dev/null +++ b/impl/testdata/raise_undefined_reference.yaml @@ -0,0 +1,9 @@ +document: + dsl: '1.0.0-alpha5' + namespace: test + name: raise-undefined-reference + version: '1.0.0' +do: + - missingError: + raise: + error: UndefinedError diff --git a/impl/testdata/set_tasks_invalid_then.yaml b/impl/testdata/set_tasks_invalid_then.yaml new file mode 100644 index 0000000..e814b76 --- /dev/null +++ b/impl/testdata/set_tasks_invalid_then.yaml @@ -0,0 +1,13 @@ +document: + name: invalid-then-workflow + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + partialResult: 15 + then: nonExistentTask + - task2: + set: + skipped: true diff --git a/impl/testdata/set_tasks_with_termination.yaml b/impl/testdata/set_tasks_with_termination.yaml new file mode 100644 index 0000000..3e663f0 --- /dev/null +++ b/impl/testdata/set_tasks_with_termination.yaml @@ -0,0 +1,13 @@ +document: + name: termination-workflow + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + finalValue: 20 + then: end + - task2: + set: + skipped: true diff --git a/impl/testdata/set_tasks_with_then.yaml b/impl/testdata/set_tasks_with_then.yaml new file mode 100644 index 0000000..59c6717 --- /dev/null +++ b/impl/testdata/set_tasks_with_then.yaml @@ -0,0 +1,16 @@ +document: + name: then-workflow + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + value: 30 + then: task3 + - task2: + set: + skipped: true + - task3: + set: + result: "${ .value * 3 }" diff --git a/model/endpoint.go b/model/endpoint.go index 9c59fb5..38e2cea 100644 --- a/model/endpoint.go +++ b/model/endpoint.go @@ -33,6 +33,7 @@ var LiteralUriTemplatePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+\-.]*:// type URITemplate interface { IsURITemplate() bool String() string + GetValue() interface{} } // UnmarshalURITemplate is a shared function for unmarshalling URITemplate fields. @@ -69,6 +70,10 @@ func (t *LiteralUriTemplate) String() string { return t.Value } +func (t *LiteralUriTemplate) GetValue() interface{} { + return t.Value +} + type LiteralUri struct { Value string `json:"-" validate:"required,uri_pattern"` // Validate pattern for URI. } @@ -85,6 +90,10 @@ func (u *LiteralUri) String() string { return u.Value } +func (u *LiteralUri) GetValue() interface{} { + return u.Value +} + type EndpointConfiguration struct { URI URITemplate `json:"uri" validate:"required"` Authentication *ReferenceableAuthenticationPolicy `json:"authentication,omitempty"` diff --git a/model/errors.go b/model/errors.go index 7d50b4a..aad13ff 100644 --- a/model/errors.go +++ b/model/errors.go @@ -22,10 +22,20 @@ const ( ) type Error struct { - Type *URITemplateOrRuntimeExpr `json:"type" validate:"required"` - Status int `json:"status" validate:"required"` - Title string `json:"title,omitempty"` - Detail string `json:"detail,omitempty"` + // A URI reference that identifies the error type. + // For cross-compatibility concerns, it is strongly recommended to use Standard Error Types whenever possible. + // Runtimes MUST ensure that the property has been set when raising or escalating the error. + Type *URITemplateOrRuntimeExpr `json:"type" validate:"required"` + // The status code generated by the origin for this occurrence of the error. + // For cross-compatibility concerns, it is strongly recommended to use HTTP Status Codes whenever possible. + // Runtimes MUST ensure that the property has been set when raising or escalating the error. + Status int `json:"status" validate:"required"` + // A short, human-readable summary of the error. + Title *StringOrRuntimeExpr `json:"title,omitempty"` + // A human-readable explanation specific to this occurrence of the error. + Detail *StringOrRuntimeExpr `json:"detail,omitempty"` + // A JSON Pointer used to reference the component the error originates from. + // Runtimes MUST set the property when raising or escalating the error. Otherwise ignore. Instance *JsonPointerOrRuntimeExpression `json:"instance,omitempty" validate:"omitempty"` } @@ -68,8 +78,8 @@ func newError(errType string, status int, title string, detail error, instance s return &Error{ Type: NewUriTemplate(errType), Status: status, - Title: title, - Detail: detail.Error(), + Title: NewStringOrRuntimeExpr(title), + Detail: NewStringOrRuntimeExpr(detail.Error()), Instance: &JsonPointerOrRuntimeExpression{ Value: instance, }, @@ -79,7 +89,7 @@ func newError(errType string, status int, title string, detail error, instance s return &Error{ Type: NewUriTemplate(errType), Status: status, - Title: title, + Title: NewStringOrRuntimeExpr(title), Instance: &JsonPointerOrRuntimeExpression{ Value: instance, }, diff --git a/model/objects.go b/model/objects.go index afbdd48..d79ac55 100644 --- a/model/objects.go +++ b/model/objects.go @@ -21,11 +21,31 @@ import ( "regexp" ) +var _ Object = &ObjectOrString{} +var _ Object = &ObjectOrRuntimeExpr{} +var _ Object = &RuntimeExpression{} +var _ Object = &URITemplateOrRuntimeExpr{} +var _ Object = &StringOrRuntimeExpr{} +var _ Object = &JsonPointerOrRuntimeExpression{} + +type Object interface { + String() string + GetValue() interface{} +} + // ObjectOrString is a type that can hold either a string or an object. type ObjectOrString struct { Value interface{} `validate:"object_or_string"` } +func (o *ObjectOrString) String() string { + return fmt.Sprintf("%v", o.Value) +} + +func (o *ObjectOrString) GetValue() interface{} { + return o.Value +} + // UnmarshalJSON unmarshals data into either a string or an object. func (o *ObjectOrString) UnmarshalJSON(data []byte) error { var asString string @@ -53,6 +73,14 @@ type ObjectOrRuntimeExpr struct { Value interface{} `json:"-" validate:"object_or_runtime_expr"` // Custom validation tag. } +func (o *ObjectOrRuntimeExpr) String() string { + return fmt.Sprintf("%v", o.Value) +} + +func (o *ObjectOrRuntimeExpr) GetValue() interface{} { + return o.Value +} + func (o *ObjectOrRuntimeExpr) AsStringOrMap() interface{} { switch o.Value.(type) { case map[string]interface{}: @@ -114,11 +142,21 @@ func (o *ObjectOrRuntimeExpr) Validate() error { return nil } +func NewStringOrRuntimeExpr(value string) *StringOrRuntimeExpr { + return &StringOrRuntimeExpr{ + Value: value, + } +} + // StringOrRuntimeExpr is a type that can hold either a RuntimeExpression or a string. type StringOrRuntimeExpr struct { Value interface{} `json:"-" validate:"string_or_runtime_expr"` // Custom validation tag. } +func (s *StringOrRuntimeExpr) AsObjectOrRuntimeExpr() *ObjectOrRuntimeExpr { + return &ObjectOrRuntimeExpr{Value: s.Value} +} + // UnmarshalJSON unmarshals data into either a RuntimeExpression or a string. func (s *StringOrRuntimeExpr) UnmarshalJSON(data []byte) error { // Attempt to decode as a RuntimeExpression @@ -162,6 +200,10 @@ func (s *StringOrRuntimeExpr) String() string { } } +func (s *StringOrRuntimeExpr) GetValue() interface{} { + return s.Value +} + // URITemplateOrRuntimeExpr represents a type that can be a URITemplate or a RuntimeExpression. type URITemplateOrRuntimeExpr struct { Value interface{} `json:"-" validate:"uri_template_or_runtime_expr"` // Custom validation. @@ -223,10 +265,16 @@ func (u *URITemplateOrRuntimeExpr) String() string { return v.String() case RuntimeExpression: return v.String() + case string: + return v } return "" } +func (u *URITemplateOrRuntimeExpr) GetValue() interface{} { + return u.Value +} + // JsonPointerOrRuntimeExpression represents a type that can be a JSON Pointer or a RuntimeExpression. type JsonPointerOrRuntimeExpression struct { Value interface{} `json:"-" validate:"json_pointer_or_runtime_expr"` // Custom validation tag. @@ -282,6 +330,10 @@ func (j *JsonPointerOrRuntimeExpression) String() string { } } +func (j *JsonPointerOrRuntimeExpression) GetValue() interface{} { + return j.Value +} + func (j *JsonPointerOrRuntimeExpression) IsValid() bool { return JSONPointerPattern.MatchString(j.String()) } diff --git a/model/runtime_expression.go b/model/runtime_expression.go index f7ba5c8..68c21ac 100644 --- a/model/runtime_expression.go +++ b/model/runtime_expression.go @@ -65,3 +65,7 @@ func (r *RuntimeExpression) MarshalJSON() ([]byte, error) { func (r *RuntimeExpression) String() string { return r.Value } + +func (r *RuntimeExpression) GetValue() interface{} { + return r.Value +} diff --git a/model/task.go b/model/task.go index 2e57ab1..8769e63 100644 --- a/model/task.go +++ b/model/task.go @@ -67,6 +67,28 @@ func (ntm *NamedTaskMap) UnmarshalJSON(data []byte) error { // TaskList represents a list of named tasks to perform. type TaskList []*TaskItem +// Next gets the next item in the list based on the current index +func (tl *TaskList) Next(currentIdx int) (int, *TaskItem) { + if currentIdx == -1 || currentIdx >= len(*tl) { + return -1, nil + } + + current := (*tl)[currentIdx] + if current.GetBase() != nil && current.GetBase().Then != nil { + then := current.GetBase().Then + if then.IsTermination() { + return -1, nil + } + return tl.KeyAndIndex(then.Value) + } + + // Proceed sequentially if no 'then' is specified + if currentIdx+1 < len(*tl) { + return currentIdx + 1, (*tl)[currentIdx+1] + } + return -1, nil +} + // UnmarshalJSON for TaskList to ensure proper deserialization. func (tl *TaskList) UnmarshalJSON(data []byte) error { var rawTasks []json.RawMessage @@ -161,12 +183,18 @@ func (tl *TaskList) MarshalJSON() ([]byte, error) { // Key retrieves a TaskItem by its key. func (tl *TaskList) Key(key string) *TaskItem { - for _, item := range *tl { + _, keyItem := tl.KeyAndIndex(key) + return keyItem +} + +func (tl *TaskList) KeyAndIndex(key string) (int, *TaskItem) { + for i, item := range *tl { if item.Key == key { - return item + return i, item } } - return nil + // TODO: Add logging here for missing task references + return -1, nil } // TaskItem represents a named task and its associated definition. diff --git a/model/task_test.go b/model/task_test.go index 6fa5019..c3d869c 100644 --- a/model/task_test.go +++ b/model/task_test.go @@ -119,3 +119,70 @@ func TestTaskList_Validation(t *testing.T) { } } + +func TestTaskList_Next_Sequential(t *testing.T) { + tasks := TaskList{ + &TaskItem{Key: "task1", Task: &SetTask{Set: map[string]interface{}{"key1": "value1"}}}, + &TaskItem{Key: "task2", Task: &SetTask{Set: map[string]interface{}{"key2": "value2"}}}, + &TaskItem{Key: "task3", Task: &SetTask{Set: map[string]interface{}{"key3": "value3"}}}, + } + + idx, currentTask := 0, tasks[0] + assert.Equal(t, "task1", currentTask.Key) + + idx, currentTask = tasks.Next(idx) + assert.Equal(t, "task2", currentTask.Key) + + idx, currentTask = tasks.Next(idx) + assert.Equal(t, "task3", currentTask.Key) + + idx, currentTask = tasks.Next(idx) + assert.Nil(t, currentTask) + assert.Equal(t, -1, idx) +} + +func TestTaskList_Next_WithThenDirective(t *testing.T) { + tasks := TaskList{ + &TaskItem{Key: "task1", Task: &SetTask{TaskBase: TaskBase{Then: &FlowDirective{Value: "task3"}}, Set: map[string]interface{}{"key1": "value1"}}}, + &TaskItem{Key: "task2", Task: &SetTask{Set: map[string]interface{}{"key2": "value2"}}}, + &TaskItem{Key: "task3", Task: &SetTask{Set: map[string]interface{}{"key3": "value3"}}}, + } + + idx, currentTask := 0, tasks[0] + assert.Equal(t, "task1", currentTask.Key) + + idx, currentTask = tasks.Next(idx) + assert.Equal(t, "task3", currentTask.Key) + + idx, currentTask = tasks.Next(idx) + assert.Nil(t, currentTask) + assert.Equal(t, -1, idx) +} + +func TestTaskList_Next_Termination(t *testing.T) { + tasks := TaskList{ + &TaskItem{Key: "task1", Task: &SetTask{TaskBase: TaskBase{Then: &FlowDirective{Value: "end"}}, Set: map[string]interface{}{"key1": "value1"}}}, + &TaskItem{Key: "task2", Task: &SetTask{Set: map[string]interface{}{"key2": "value2"}}}, + } + + idx, currentTask := 0, tasks[0] + assert.Equal(t, "task1", currentTask.Key) + + idx, currentTask = tasks.Next(idx) + assert.Nil(t, currentTask) + assert.Equal(t, -1, idx) +} + +func TestTaskList_Next_InvalidThenReference(t *testing.T) { + tasks := TaskList{ + &TaskItem{Key: "task1", Task: &SetTask{TaskBase: TaskBase{Then: &FlowDirective{Value: "unknown"}}, Set: map[string]interface{}{"key1": "value1"}}}, + &TaskItem{Key: "task2", Task: &SetTask{Set: map[string]interface{}{"key2": "value2"}}}, + } + + idx, currentTask := 0, tasks[0] + assert.Equal(t, "task1", currentTask.Key) + + idx, currentTask = tasks.Next(idx) + assert.Nil(t, currentTask) + assert.Equal(t, -1, idx) +} diff --git a/model/workflow.go b/model/workflow.go index 17973e1..313a9e5 100644 --- a/model/workflow.go +++ b/model/workflow.go @@ -221,6 +221,11 @@ func (f *FlowDirective) IsEnum() bool { return exists } +// IsTermination checks if the FlowDirective matches FlowDirectiveExit or FlowDirectiveEnd. +func (f *FlowDirective) IsTermination() bool { + return f.Value == string(FlowDirectiveExit) || f.Value == string(FlowDirectiveEnd) +} + func (f *FlowDirective) UnmarshalJSON(data []byte) error { var value string if err := json.Unmarshal(data, &value); err != nil { From 76fea46413e5539785ee84b48ad1eb2716d9f949 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Thu, 13 Feb 2025 16:31:31 -0500 Subject: [PATCH 04/11] Task Do implementation and refactoring Signed-off-by: Ricardo Zanini --- impl/context.go | 120 ++++++----- impl/runner.go | 203 ++---------------- impl/{task.go => task_runner.go} | 48 ++++- impl/task_runner_do.go | 161 ++++++++++++++ ...aise_test.go => task_runner_raise_test.go} | 6 +- impl/{task_test.go => task_runner_test.go} | 0 impl/utils.go | 25 +++ model/task_raise_test.go | 8 +- 8 files changed, 320 insertions(+), 251 deletions(-) rename impl/{task.go => task_runner.go} (70%) create mode 100644 impl/task_runner_do.go rename impl/{task_raise_test.go => task_runner_raise_test.go} (98%) rename impl/{task_test.go => task_runner_test.go} (100%) diff --git a/impl/context.go b/impl/context.go index b002c9f..789a23f 100644 --- a/impl/context.go +++ b/impl/context.go @@ -10,8 +10,8 @@ type ctxKey string const runnerCtxKey ctxKey = "wfRunnerContext" -// WorkflowRunnerContext holds the necessary data for the workflow execution within the instance. -type WorkflowRunnerContext struct { +// WorkflowContext holds the necessary data for the workflow execution within the instance. +type WorkflowContext struct { mu sync.Mutex input interface{} // input can hold any type output interface{} // output can hold any type @@ -20,112 +20,116 @@ type WorkflowRunnerContext struct { TasksStatusPhase map[string][]StatusPhaseLog // Holds `$context` as the key } -func (runnerCtx *WorkflowRunnerContext) SetStatus(status StatusPhase) { - runnerCtx.mu.Lock() - defer runnerCtx.mu.Unlock() - if runnerCtx.StatusPhase == nil { - runnerCtx.StatusPhase = []StatusPhaseLog{} +type TaskContext interface { + SetTaskStatus(task string, status StatusPhase) +} + +func (ctx *WorkflowContext) SetStatus(status StatusPhase) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + if ctx.StatusPhase == nil { + ctx.StatusPhase = []StatusPhaseLog{} } - runnerCtx.StatusPhase = append(runnerCtx.StatusPhase, NewStatusPhaseLog(status)) + ctx.StatusPhase = append(ctx.StatusPhase, NewStatusPhaseLog(status)) } -func (runnerCtx *WorkflowRunnerContext) SetTaskStatus(task string, status StatusPhase) { - runnerCtx.mu.Lock() - defer runnerCtx.mu.Unlock() - if runnerCtx.TasksStatusPhase == nil { - runnerCtx.TasksStatusPhase = map[string][]StatusPhaseLog{} +func (ctx *WorkflowContext) SetTaskStatus(task string, status StatusPhase) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + if ctx.TasksStatusPhase == nil { + ctx.TasksStatusPhase = map[string][]StatusPhaseLog{} } - runnerCtx.TasksStatusPhase[task] = append(runnerCtx.TasksStatusPhase[task], NewStatusPhaseLog(status)) + ctx.TasksStatusPhase[task] = append(ctx.TasksStatusPhase[task], NewStatusPhaseLog(status)) } -// SetWorkflowCtx safely sets the `$context` value -func (runnerCtx *WorkflowRunnerContext) SetWorkflowCtx(value interface{}) { - runnerCtx.mu.Lock() - defer runnerCtx.mu.Unlock() - if runnerCtx.context == nil { - runnerCtx.context = make(map[string]interface{}) +// SetInstanceCtx safely sets the `$context` value +func (ctx *WorkflowContext) SetInstanceCtx(value interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + if ctx.context == nil { + ctx.context = make(map[string]interface{}) } - runnerCtx.context["$context"] = value + ctx.context["$context"] = value } -// GetWorkflowCtx safely retrieves the `$context` value -func (runnerCtx *WorkflowRunnerContext) GetWorkflowCtx() interface{} { - runnerCtx.mu.Lock() - defer runnerCtx.mu.Unlock() - if runnerCtx.context == nil { +// GetInstanceCtx safely retrieves the `$context` value +func (ctx *WorkflowContext) GetInstanceCtx() interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() + if ctx.context == nil { return nil } - return runnerCtx.context["$context"] + return ctx.context["$context"] } // SetInput safely sets the input -func (runnerCtx *WorkflowRunnerContext) SetInput(input interface{}) { - runnerCtx.mu.Lock() - defer runnerCtx.mu.Unlock() - runnerCtx.input = input +func (ctx *WorkflowContext) SetInput(input interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + ctx.input = input } // GetInput safely retrieves the input -func (runnerCtx *WorkflowRunnerContext) GetInput() interface{} { - runnerCtx.mu.Lock() - defer runnerCtx.mu.Unlock() - return runnerCtx.input +func (ctx *WorkflowContext) GetInput() interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() + return ctx.input } // SetOutput safely sets the output -func (runnerCtx *WorkflowRunnerContext) SetOutput(output interface{}) { - runnerCtx.mu.Lock() - defer runnerCtx.mu.Unlock() - runnerCtx.output = output +func (ctx *WorkflowContext) SetOutput(output interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + ctx.output = output } // GetOutput safely retrieves the output -func (runnerCtx *WorkflowRunnerContext) GetOutput() interface{} { - runnerCtx.mu.Lock() - defer runnerCtx.mu.Unlock() - return runnerCtx.output +func (ctx *WorkflowContext) GetOutput() interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() + return ctx.output } // GetInputAsMap safely retrieves the input as a map[string]interface{}. // If input is not a map, it creates a map with an empty string key and the input as the value. -func (runnerCtx *WorkflowRunnerContext) GetInputAsMap() map[string]interface{} { - runnerCtx.mu.Lock() - defer runnerCtx.mu.Unlock() +func (ctx *WorkflowContext) GetInputAsMap() map[string]interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() - if inputMap, ok := runnerCtx.input.(map[string]interface{}); ok { + if inputMap, ok := ctx.input.(map[string]interface{}); ok { return inputMap } // If input is not a map, create a map with an empty key and set input as the value return map[string]interface{}{ - "": runnerCtx.input, + "": ctx.input, } } // GetOutputAsMap safely retrieves the output as a map[string]interface{}. // If output is not a map, it creates a map with an empty string key and the output as the value. -func (runnerCtx *WorkflowRunnerContext) GetOutputAsMap() map[string]interface{} { - runnerCtx.mu.Lock() - defer runnerCtx.mu.Unlock() +func (ctx *WorkflowContext) GetOutputAsMap() map[string]interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() - if outputMap, ok := runnerCtx.output.(map[string]interface{}); ok { + if outputMap, ok := ctx.output.(map[string]interface{}); ok { return outputMap } // If output is not a map, create a map with an empty key and set output as the value return map[string]interface{}{ - "": runnerCtx.output, + "": ctx.output, } } -// WithRunnerContext adds the WorkflowRunnerContext to a parent context -func WithRunnerContext(parent context.Context, wfCtx *WorkflowRunnerContext) context.Context { +// WithWorkflowContext adds the WorkflowContext to a parent context +func WithWorkflowContext(parent context.Context, wfCtx *WorkflowContext) context.Context { return context.WithValue(parent, runnerCtxKey, wfCtx) } -// GetRunnerContext retrieves the WorkflowRunnerContext from a context -func GetRunnerContext(ctx context.Context) (*WorkflowRunnerContext, error) { - wfCtx, ok := ctx.Value(runnerCtxKey).(*WorkflowRunnerContext) +// GetWorkflowContext retrieves the WorkflowContext from a context +func GetWorkflowContext(ctx context.Context) (*WorkflowContext, error) { + wfCtx, ok := ctx.Value(runnerCtxKey).(*WorkflowContext) if !ok { return nil, errors.New("workflow context not found") } diff --git a/impl/runner.go b/impl/runner.go index 32d0cbf..fa72c78 100644 --- a/impl/runner.go +++ b/impl/runner.go @@ -2,8 +2,6 @@ package impl import ( "context" - "fmt" - "github.com/serverlessworkflow/sdk-go/v3/expr" "github.com/serverlessworkflow/sdk-go/v3/model" ) @@ -12,14 +10,14 @@ var _ WorkflowRunner = &workflowRunnerImpl{} type WorkflowRunner interface { GetWorkflowDef() *model.Workflow Run(input interface{}) (output interface{}, err error) - GetContext() *WorkflowRunnerContext + GetContext() *WorkflowContext } func NewDefaultRunner(workflow *model.Workflow) WorkflowRunner { - wfContext := &WorkflowRunnerContext{} + wfContext := &WorkflowContext{} wfContext.SetStatus(PendingStatus) // TODO: based on the workflow definition, the context might change. - ctx := WithRunnerContext(context.Background(), wfContext) + ctx := WithWorkflowContext(context.Background(), wfContext) return &workflowRunnerImpl{ Workflow: workflow, Context: ctx, @@ -30,10 +28,14 @@ func NewDefaultRunner(workflow *model.Workflow) WorkflowRunner { type workflowRunnerImpl struct { Workflow *model.Workflow Context context.Context - RunnerCtx *WorkflowRunnerContext + RunnerCtx *WorkflowContext } -func (wr *workflowRunnerImpl) GetContext() *WorkflowRunnerContext { +func (wr *workflowRunnerImpl) GetContext() *WorkflowContext { + return wr.RunnerCtx +} + +func (wr *workflowRunnerImpl) GetTaskContext() TaskContext { return wr.RunnerCtx } @@ -55,19 +57,24 @@ func (wr *workflowRunnerImpl) Run(input interface{}) (output interface{}, err er return nil, err } + wr.RunnerCtx.SetInput(input) // Run tasks sequentially wr.RunnerCtx.SetStatus(RunningStatus) - if err = wr.executeTasks(wr.Workflow.Do); err != nil { + doRunner, err := NewDoTaskRunner(wr.Workflow.Do, wr) + if err != nil { + return nil, err + } + output, err = doRunner.Run(wr.RunnerCtx.GetInput()) + if err != nil { return nil, err } - - output = wr.RunnerCtx.GetOutput() // Process output if output, err = wr.processWorkflowOutput(output); err != nil { return nil, err } + wr.RunnerCtx.SetOutput(output) wr.RunnerCtx.SetStatus(CompletedStatus) return output, nil } @@ -92,7 +99,7 @@ func (wr *workflowRunnerImpl) processWorkflowInput(input interface{}) (interface if input, err = traverseAndEvaluate(wr.Workflow.Input.From, input, "/"); err != nil { return nil, err } - wr.RunnerCtx.SetWorkflowCtx(input) + wr.RunnerCtx.SetInstanceCtx(input) } } @@ -101,66 +108,11 @@ func (wr *workflowRunnerImpl) processWorkflowInput(input interface{}) (interface return input, nil } -// executeTasks runs all defined tasks sequentially. -func (wr *workflowRunnerImpl) executeTasks(tasks *model.TaskList) error { - if tasks == nil { - return nil - } - - idx := 0 - currentTask := (*tasks)[idx] - - for currentTask != nil { - wr.RunnerCtx.SetInput(wr.RunnerCtx.GetOutput()) - if shouldRun, err := wr.shouldRunTask(currentTask); err != nil { - return err - } else if !shouldRun { - wr.RunnerCtx.SetOutput(wr.RunnerCtx.GetInput()) - idx, currentTask = tasks.Next(idx) - continue - } - - wr.RunnerCtx.SetTaskStatus(currentTask.Key, PendingStatus) - runner, err := NewTaskRunner(currentTask.Key, currentTask.Task, wr) - if err != nil { - return err - } - - wr.RunnerCtx.SetTaskStatus(currentTask.Key, RunningStatus) - var output interface{} - if output, err = wr.runTask(runner, currentTask.Task.GetBase()); err != nil { - wr.RunnerCtx.SetTaskStatus(currentTask.Key, FaultedStatus) - return err - } - // TODO: make sure that `output` is a map[string]interface{}, so compatible to JSON traversal. - - wr.RunnerCtx.SetTaskStatus(currentTask.Key, CompletedStatus) - wr.RunnerCtx.SetOutput(output) - - idx, currentTask = tasks.Next(idx) - } - - return nil -} - -func (wr *workflowRunnerImpl) shouldRunTask(task *model.TaskItem) (bool, error) { - if task.GetBase().If != nil { - output, err := expr.TraverseAndEvaluate(task.GetBase().If.String(), wr.RunnerCtx.GetInput()) - if err != nil { - return false, model.NewErrExpression(err, task.Key) - } - if result, ok := output.(bool); ok && !result { - return false, nil - } - } - return true, nil -} - // processWorkflowOutput applies output transformations. func (wr *workflowRunnerImpl) processWorkflowOutput(output interface{}) (interface{}, error) { if wr.Workflow.Output != nil { var err error - if output, err = traverseAndEvaluate(wr.Workflow.Output.As, wr.RunnerCtx.GetOutput(), "/"); err != nil { + if output, err = traverseAndEvaluate(wr.Workflow.Output.As, output, "/"); err != nil { return nil, err } @@ -174,120 +126,3 @@ func (wr *workflowRunnerImpl) processWorkflowOutput(output interface{}) (interfa } // ----------------- Task funcs ------------------- // - -// TODO: refactor to receive a resolver handler instead of the workflow runner - -// NewTaskRunner creates a TaskRunner instance based on the task type. -func NewTaskRunner(taskName string, task model.Task, wr *workflowRunnerImpl) (TaskRunner, error) { - switch t := task.(type) { - case *model.SetTask: - return NewSetTaskRunner(taskName, t) - case *model.RaiseTask: - if err := wr.resolveErrorDefinition(t); err != nil { - return nil, err - } - return NewRaiseTaskRunner(taskName, t) - default: - return nil, fmt.Errorf("unsupported task type '%T' for task '%s'", t, taskName) - } -} - -// TODO: can e refactored to a definition resolver callable from the context -func (wr *workflowRunnerImpl) resolveErrorDefinition(t *model.RaiseTask) error { - if t.Raise.Error.Ref != nil { - notFoundErr := model.NewErrValidation(fmt.Errorf("%v error definition not found in 'uses'", t.Raise.Error.Ref), "") - if wr.Workflow.Use != nil && wr.Workflow.Use.Errors != nil { - definition, ok := wr.Workflow.Use.Errors[*t.Raise.Error.Ref] - if !ok { - return notFoundErr - } - t.Raise.Error.Definition = definition - return nil - } - return notFoundErr - } - return nil -} - -// runTask executes an individual task. -func (wr *workflowRunnerImpl) runTask(runner TaskRunner, task *model.TaskBase) (output interface{}, err error) { - taskInput := wr.RunnerCtx.GetInput() - taskName := runner.GetTaskName() - - defer func() { - if err != nil { - err = wr.wrapWorkflowError(err, taskName) - } - }() - - if task.Input != nil { - if taskInput, err = wr.validateAndEvaluateTaskInput(task, taskInput, taskName); err != nil { - return nil, err - } - } - - output, err = runner.Run(taskInput) - if err != nil { - return nil, err - } - - if output, err = wr.validateAndEvaluateTaskOutput(task, output, taskName); err != nil { - return nil, err - } - - return output, nil -} - -// validateAndEvaluateTaskInput processes task input validation and transformation. -func (wr *workflowRunnerImpl) validateAndEvaluateTaskInput(task *model.TaskBase, taskInput interface{}, taskName string) (output interface{}, err error) { - if task.Input == nil { - return taskInput, nil - } - - if err = validateSchema(taskInput, task.Input.Schema, taskName); err != nil { - return nil, err - } - - if output, err = traverseAndEvaluate(task.Input.From, taskInput, taskName); err != nil { - return nil, err - } - - return output, nil -} - -// validateAndEvaluateTaskOutput processes task output validation and transformation. -func (wr *workflowRunnerImpl) validateAndEvaluateTaskOutput(task *model.TaskBase, taskOutput interface{}, taskName string) (output interface{}, err error) { - if task.Output == nil { - return taskOutput, nil - } - - if output, err = traverseAndEvaluate(task.Output.As, taskOutput, taskName); err != nil { - return nil, err - } - - if err = validateSchema(output, task.Output.Schema, taskName); err != nil { - return nil, err - } - - return output, nil -} - -func validateSchema(data interface{}, schema *model.Schema, taskName string) error { - if schema != nil { - if err := ValidateJSONSchema(data, schema); err != nil { - return model.NewErrValidation(err, taskName) - } - } - return nil -} - -func traverseAndEvaluate(runtimeExpr *model.ObjectOrRuntimeExpr, input interface{}, taskName string) (output interface{}, err error) { - if runtimeExpr == nil { - return input, nil - } - output, err = expr.TraverseAndEvaluate(runtimeExpr.AsStringOrMap(), input) - if err != nil { - return nil, model.NewErrExpression(err, taskName) - } - return output, nil -} diff --git a/impl/task.go b/impl/task_runner.go similarity index 70% rename from impl/task.go rename to impl/task_runner.go index 0e01293..b718c0b 100644 --- a/impl/task.go +++ b/impl/task_runner.go @@ -8,6 +8,7 @@ import ( var _ TaskRunner = &SetTaskRunner{} var _ TaskRunner = &RaiseTaskRunner{} +var _ TaskRunner = &ForTaskRunner{} type TaskRunner interface { Run(input interface{}) (interface{}, error) @@ -52,8 +53,11 @@ func (s *SetTaskRunner) Run(input interface{}) (output interface{}, err error) { return output, nil } -func NewRaiseTaskRunner(taskName string, task *model.RaiseTask) (*RaiseTaskRunner, error) { - if task == nil || task.Raise.Error.Definition == nil { +func NewRaiseTaskRunner(taskName string, task *model.RaiseTask, workflowDef *model.Workflow) (*RaiseTaskRunner, error) { + if err := resolveErrorDefinition(task, workflowDef); err != nil { + return nil, err + } + if task.Raise.Error.Definition == nil { return nil, model.NewErrValidation(fmt.Errorf("no raise configuration provided for RaiseTask %s", taskName), taskName) } return &RaiseTaskRunner{ @@ -62,6 +66,23 @@ func NewRaiseTaskRunner(taskName string, task *model.RaiseTask) (*RaiseTaskRunne }, nil } +// TODO: can e refactored to a definition resolver callable from the context +func resolveErrorDefinition(t *model.RaiseTask, workflowDef *model.Workflow) error { + if workflowDef != nil && t.Raise.Error.Ref != nil { + notFoundErr := model.NewErrValidation(fmt.Errorf("%v error definition not found in 'uses'", t.Raise.Error.Ref), "") + if workflowDef.Use != nil && workflowDef.Use.Errors != nil { + definition, ok := workflowDef.Use.Errors[*t.Raise.Error.Ref] + if !ok { + return notFoundErr + } + t.Raise.Error.Definition = definition + return nil + } + return notFoundErr + } + return nil +} + type RaiseTaskRunner struct { Task *model.RaiseTask TaskName string @@ -113,3 +134,26 @@ func (r *RaiseTaskRunner) Run(input interface{}) (output interface{}, err error) func (r *RaiseTaskRunner) GetTaskName() string { return r.TaskName } + +func NewForTaskRunner(taskName string, task *model.ForTask) (*ForTaskRunner, error) { + if task == nil { + return nil, model.NewErrValidation(fmt.Errorf("invalid For task %s", taskName), taskName) + } + return &ForTaskRunner{ + Task: task, + TaskName: taskName, + }, nil +} + +type ForTaskRunner struct { + Task *model.ForTask + TaskName string +} + +func (f *ForTaskRunner) Run(input interface{}) (interface{}, error) { + return input, nil +} + +func (f *ForTaskRunner) GetTaskName() string { + return f.TaskName +} diff --git a/impl/task_runner_do.go b/impl/task_runner_do.go new file mode 100644 index 0000000..7f03205 --- /dev/null +++ b/impl/task_runner_do.go @@ -0,0 +1,161 @@ +package impl + +import ( + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/expr" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +var _ TaskRunner = &DoTaskRunner{} + +type TaskSupport interface { + GetTaskContext() TaskContext + GetWorkflowDef() *model.Workflow +} + +// TODO: refactor to receive a resolver handler instead of the workflow runner + +// NewTaskRunner creates a TaskRunner instance based on the task type. +func NewTaskRunner(taskName string, task model.Task, taskSupport TaskSupport) (TaskRunner, error) { + switch t := task.(type) { + case *model.SetTask: + return NewSetTaskRunner(taskName, t) + case *model.RaiseTask: + return NewRaiseTaskRunner(taskName, t, taskSupport.GetWorkflowDef()) + case *model.DoTask: + return NewDoTaskRunner(t.Do, taskSupport) + default: + return nil, fmt.Errorf("unsupported task type '%T' for task '%s'", t, taskName) + } +} + +func NewDoTaskRunner(taskList *model.TaskList, taskSupport TaskSupport) (*DoTaskRunner, error) { + return &DoTaskRunner{ + TaskList: taskList, + TaskSupport: taskSupport, + }, nil +} + +type DoTaskRunner struct { + TaskList *model.TaskList + TaskSupport TaskSupport +} + +func (d *DoTaskRunner) Run(input interface{}) (output interface{}, err error) { + if d.TaskList == nil { + return input, nil + } + return d.executeTasks(input, d.TaskList) +} + +func (d *DoTaskRunner) GetTaskName() string { + return "" +} + +// executeTasks runs all defined tasks sequentially. +func (d *DoTaskRunner) executeTasks(input interface{}, tasks *model.TaskList) (output interface{}, err error) { + output = deepCloneValue(input) + if tasks == nil { + return output, nil + } + + idx := 0 + currentTask := (*tasks)[idx] + ctx := d.TaskSupport.GetTaskContext() + + for currentTask != nil { + if shouldRun, err := d.shouldRunTask(input, currentTask); err != nil { + return output, err + } else if !shouldRun { + idx, currentTask = tasks.Next(idx) + continue + } + + ctx.SetTaskStatus(currentTask.Key, PendingStatus) + runner, err := NewTaskRunner(currentTask.Key, currentTask.Task, d.TaskSupport) + if err != nil { + return output, err + } + + ctx.SetTaskStatus(currentTask.Key, RunningStatus) + if output, err = d.runTask(input, runner, currentTask.Task.GetBase()); err != nil { + ctx.SetTaskStatus(currentTask.Key, FaultedStatus) + return output, err + } + + ctx.SetTaskStatus(currentTask.Key, CompletedStatus) + input = deepCloneValue(output) + idx, currentTask = tasks.Next(idx) + } + + return output, nil +} + +func (d *DoTaskRunner) shouldRunTask(input interface{}, task *model.TaskItem) (bool, error) { + if task.GetBase().If != nil { + output, err := expr.TraverseAndEvaluate(task.GetBase().If.String(), input) + if err != nil { + return false, model.NewErrExpression(err, task.Key) + } + if result, ok := output.(bool); ok && !result { + return false, nil + } + } + return true, nil +} + +// runTask executes an individual task. +func (d *DoTaskRunner) runTask(input interface{}, runner TaskRunner, task *model.TaskBase) (output interface{}, err error) { + taskName := runner.GetTaskName() + + if task.Input != nil { + if input, err = d.validateAndEvaluateTaskInput(task, input, taskName); err != nil { + return nil, err + } + } + + output, err = runner.Run(input) + if err != nil { + return nil, err + } + + if output, err = d.validateAndEvaluateTaskOutput(task, output, taskName); err != nil { + return nil, err + } + + return output, nil +} + +// validateAndEvaluateTaskInput processes task input validation and transformation. +func (d *DoTaskRunner) validateAndEvaluateTaskInput(task *model.TaskBase, taskInput interface{}, taskName string) (output interface{}, err error) { + if task.Input == nil { + return taskInput, nil + } + + if err = validateSchema(taskInput, task.Input.Schema, taskName); err != nil { + return nil, err + } + + if output, err = traverseAndEvaluate(task.Input.From, taskInput, taskName); err != nil { + return nil, err + } + + return output, nil +} + +// validateAndEvaluateTaskOutput processes task output validation and transformation. +func (d *DoTaskRunner) validateAndEvaluateTaskOutput(task *model.TaskBase, taskOutput interface{}, taskName string) (output interface{}, err error) { + if task.Output == nil { + return taskOutput, nil + } + + if output, err = traverseAndEvaluate(task.Output.As, taskOutput, taskName); err != nil { + return nil, err + } + + if err = validateSchema(output, task.Output.Schema, taskName); err != nil { + return nil, err + } + + return output, nil +} diff --git a/impl/task_raise_test.go b/impl/task_runner_raise_test.go similarity index 98% rename from impl/task_raise_test.go rename to impl/task_runner_raise_test.go index 8733301..57d9821 100644 --- a/impl/task_raise_test.go +++ b/impl/task_runner_raise_test.go @@ -24,7 +24,7 @@ func TestRaiseTaskRunner_WithDefinedError(t *testing.T) { }, } - runner, err := NewRaiseTaskRunner("task_raise_defined", raiseTask) + runner, err := NewRaiseTaskRunner("task_raise_defined", raiseTask, nil) assert.NoError(t, err) output, err := runner.Run(input) @@ -51,7 +51,7 @@ func TestRaiseTaskRunner_WithReferencedError(t *testing.T) { }, } - runner, err := NewRaiseTaskRunner("task_raise_ref", raiseTask) + runner, err := NewRaiseTaskRunner("task_raise_ref", raiseTask, nil) assert.Error(t, err) assert.Nil(t, runner) } @@ -74,7 +74,7 @@ func TestRaiseTaskRunner_TimeoutErrorWithExpression(t *testing.T) { }, } - runner, err := NewRaiseTaskRunner("task_raise_timeout_expr", raiseTask) + runner, err := NewRaiseTaskRunner("task_raise_timeout_expr", raiseTask, nil) assert.NoError(t, err) output, err := runner.Run(input) diff --git a/impl/task_test.go b/impl/task_runner_test.go similarity index 100% rename from impl/task_test.go rename to impl/task_runner_test.go diff --git a/impl/utils.go b/impl/utils.go index fa80ef9..b3d5cec 100644 --- a/impl/utils.go +++ b/impl/utils.go @@ -1,5 +1,10 @@ package impl +import ( + "github.com/serverlessworkflow/sdk-go/v3/expr" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + // Deep clone a map to avoid modifying the original object func deepClone(obj map[string]interface{}) map[string]interface{} { clone := make(map[string]interface{}) @@ -22,3 +27,23 @@ func deepCloneValue(value interface{}) interface{} { } return value } + +func validateSchema(data interface{}, schema *model.Schema, taskName string) error { + if schema != nil { + if err := ValidateJSONSchema(data, schema); err != nil { + return model.NewErrValidation(err, taskName) + } + } + return nil +} + +func traverseAndEvaluate(runtimeExpr *model.ObjectOrRuntimeExpr, input interface{}, taskName string) (output interface{}, err error) { + if runtimeExpr == nil { + return input, nil + } + output, err = expr.TraverseAndEvaluate(runtimeExpr.AsStringOrMap(), input) + if err != nil { + return nil, model.NewErrExpression(err, taskName) + } + return output, nil +} diff --git a/model/task_raise_test.go b/model/task_raise_test.go index 49ede54..1aa3d3b 100644 --- a/model/task_raise_test.go +++ b/model/task_raise_test.go @@ -38,8 +38,8 @@ func TestRaiseTask_MarshalJSON(t *testing.T) { Definition: &Error{ Type: &URITemplateOrRuntimeExpr{Value: "http://example.com/error"}, Status: 500, - Title: "Internal Server Error", - Detail: "An unexpected error occurred.", + Title: NewStringOrRuntimeExpr("Internal Server Error"), + Detail: NewStringOrRuntimeExpr("An unexpected error occurred."), }, }, }, @@ -94,6 +94,6 @@ func TestRaiseTask_UnmarshalJSON(t *testing.T) { assert.Equal(t, map[string]interface{}{"meta": "data"}, raiseTask.Metadata) assert.Equal(t, "http://example.com/error", raiseTask.Raise.Error.Definition.Type.String()) assert.Equal(t, 500, raiseTask.Raise.Error.Definition.Status) - assert.Equal(t, "Internal Server Error", raiseTask.Raise.Error.Definition.Title) - assert.Equal(t, "An unexpected error occurred.", raiseTask.Raise.Error.Definition.Detail) + assert.Equal(t, "Internal Server Error", raiseTask.Raise.Error.Definition.Title.String()) + assert.Equal(t, "An unexpected error occurred.", raiseTask.Raise.Error.Definition.Detail.String()) } From f74de33b747bc836fb744da2e2f67bdc8c2ff930 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Thu, 13 Feb 2025 16:33:12 -0500 Subject: [PATCH 05/11] Upgrade Upload Artifact Signed-off-by: Ricardo Zanini --- .github/workflows/Go-SDK-PR-Check.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Go-SDK-PR-Check.yaml b/.github/workflows/Go-SDK-PR-Check.yaml index 8d4da2f..9e9416c 100644 --- a/.github/workflows/Go-SDK-PR-Check.yaml +++ b/.github/workflows/Go-SDK-PR-Check.yaml @@ -93,7 +93,7 @@ jobs: run: go test ./... -coverprofile=test_coverage.out -covermode=atomic - name: Upload Coverage Report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Test Coverage Report path: test_coverage.out @@ -120,7 +120,7 @@ jobs: - name: Upload JUnit Report if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Integration Test JUnit Report path: ./integration-test-junit.xml From 98207048673bcf91997cd11403b6fb907d1d103f Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Fri, 14 Feb 2025 15:54:40 -0500 Subject: [PATCH 06/11] Add missing license headers Signed-off-by: Ricardo Zanini --- expr/expr.go | 14 +++++ impl/context.go | 14 +++++ impl/json_schema.go | 14 +++++ impl/runner.go | 57 +++++++++---------- impl/status_phase.go | 14 +++++ impl/task_runner.go | 30 ++++++++-- impl/task_runner_do.go | 30 +++++++--- impl/task_runner_raise_test.go | 14 +++++ impl/task_runner_test.go | 14 +++++ impl/task_set_test.go | 14 +++++ impl/testdata/chained_set_tasks.yaml | 14 +++++ impl/testdata/concatenating_strings.yaml | 14 +++++ impl/testdata/conditional_logic.yaml | 14 +++++ .../conditional_logic_input_from.yaml | 14 +++++ impl/testdata/raise_conditional.yaml | 14 +++++ impl/testdata/raise_error_with_input.yaml | 14 +++++ impl/testdata/raise_inline.yaml | 14 +++++ impl/testdata/raise_reusable.yaml | 14 +++++ impl/testdata/raise_undefined_reference.yaml | 14 +++++ impl/testdata/sequential_set_colors.yaml | 14 +++++ .../sequential_set_colors_output_as.yaml | 14 +++++ impl/testdata/set_tasks_invalid_then.yaml | 14 +++++ impl/testdata/set_tasks_with_termination.yaml | 14 +++++ impl/testdata/set_tasks_with_then.yaml | 14 +++++ impl/testdata/task_export_schema.yaml | 14 +++++ impl/testdata/task_input_schema.yaml | 14 +++++ impl/testdata/task_output_schema.yaml | 14 +++++ ...task_output_schema_with_dynamic_value.yaml | 14 +++++ impl/testdata/workflow_input_schema.yaml | 14 +++++ impl/utils.go | 32 +++++++++++ model/errors.go | 14 +++++ model/errors_test.go | 14 +++++ 32 files changed, 497 insertions(+), 44 deletions(-) diff --git a/expr/expr.go b/expr/expr.go index 711d899..247eb3e 100644 --- a/expr/expr.go +++ b/expr/expr.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package expr import ( diff --git a/impl/context.go b/impl/context.go index 789a23f..ae9375e 100644 --- a/impl/context.go +++ b/impl/context.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import ( diff --git a/impl/json_schema.go b/impl/json_schema.go index c0f62ad..5996606 100644 --- a/impl/json_schema.go +++ b/impl/json_schema.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import ( diff --git a/impl/runner.go b/impl/runner.go index fa72c78..5d8583c 100644 --- a/impl/runner.go +++ b/impl/runner.go @@ -1,7 +1,22 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import ( "context" + "fmt" "github.com/serverlessworkflow/sdk-go/v3/model" ) @@ -53,7 +68,7 @@ func (wr *workflowRunnerImpl) Run(input interface{}) (output interface{}, err er }() // Process input - if input, err = wr.processWorkflowInput(input); err != nil { + if input, err = wr.processInput(input); err != nil { return nil, err } @@ -70,7 +85,7 @@ func (wr *workflowRunnerImpl) Run(input interface{}) (output interface{}, err er } // Process output - if output, err = wr.processWorkflowOutput(output); err != nil { + if output, err = wr.processOutput(output); err != nil { return nil, err } @@ -84,45 +99,25 @@ func (wr *workflowRunnerImpl) wrapWorkflowError(err error, taskName string) erro if knownErr := model.AsError(err); knownErr != nil { return knownErr.WithInstanceRef(wr.Workflow, taskName) } - return model.NewErrRuntime(err, taskName) + return model.NewErrRuntime(fmt.Errorf("workflow '%s', task '%s': %w", wr.Workflow.Document.Name, taskName, err), taskName) } -// processWorkflowInput validates and transforms input if needed. -func (wr *workflowRunnerImpl) processWorkflowInput(input interface{}) (interface{}, error) { +// processInput validates and transforms input if needed. +func (wr *workflowRunnerImpl) processInput(input interface{}) (output interface{}, err error) { if wr.Workflow.Input != nil { - var err error - if err = validateSchema(input, wr.Workflow.Input.Schema, "/"); err != nil { + output, err = processIO(input, wr.Workflow.Input.Schema, wr.Workflow.Input.From, "/") + if err != nil { return nil, err } - - if wr.Workflow.Input.From != nil { - if input, err = traverseAndEvaluate(wr.Workflow.Input.From, input, "/"); err != nil { - return nil, err - } - wr.RunnerCtx.SetInstanceCtx(input) - } + return output, nil } - - wr.RunnerCtx.SetInput(input) - wr.RunnerCtx.SetOutput(input) return input, nil } -// processWorkflowOutput applies output transformations. -func (wr *workflowRunnerImpl) processWorkflowOutput(output interface{}) (interface{}, error) { +// processOutput applies output transformations. +func (wr *workflowRunnerImpl) processOutput(output interface{}) (interface{}, error) { if wr.Workflow.Output != nil { - var err error - if output, err = traverseAndEvaluate(wr.Workflow.Output.As, output, "/"); err != nil { - return nil, err - } - - if err = validateSchema(output, wr.Workflow.Output.Schema, "/"); err != nil { - return nil, err - } + return processIO(output, wr.Workflow.Output.Schema, wr.Workflow.Output.As, "/") } - - wr.RunnerCtx.SetOutput(output) return output, nil } - -// ----------------- Task funcs ------------------- // diff --git a/impl/status_phase.go b/impl/status_phase.go index f2b8f41..ca61fad 100644 --- a/impl/status_phase.go +++ b/impl/status_phase.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import "time" diff --git a/impl/task_runner.go b/impl/task_runner.go index b718c0b..e346db2 100644 --- a/impl/task_runner.go +++ b/impl/task_runner.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import ( @@ -34,10 +48,6 @@ func (s *SetTaskRunner) GetTaskName() string { return s.TaskName } -func (s *SetTaskRunner) String() string { - return fmt.Sprintf("SetTaskRunner{Task: %s}", s.GetTaskName()) -} - func (s *SetTaskRunner) Run(input interface{}) (output interface{}, err error) { setObject := deepClone(s.Task.Set) result, err := expr.TraverseAndEvaluate(setObject, input) @@ -135,19 +145,27 @@ func (r *RaiseTaskRunner) GetTaskName() string { return r.TaskName } -func NewForTaskRunner(taskName string, task *model.ForTask) (*ForTaskRunner, error) { - if task == nil { +func NewForTaskRunner(taskName string, task *model.ForTask, taskSupport TaskSupport) (*ForTaskRunner, error) { + if task == nil || task.Do == nil { return nil, model.NewErrValidation(fmt.Errorf("invalid For task %s", taskName), taskName) } + + doRunner, err := NewDoTaskRunner(task.Do, taskSupport) + if err != nil { + return nil, err + } + return &ForTaskRunner{ Task: task, TaskName: taskName, + DoRunner: doRunner, }, nil } type ForTaskRunner struct { Task *model.ForTask TaskName string + DoRunner *DoTaskRunner } func (f *ForTaskRunner) Run(input interface{}) (interface{}, error) { diff --git a/impl/task_runner_do.go b/impl/task_runner_do.go index 7f03205..5426789 100644 --- a/impl/task_runner_do.go +++ b/impl/task_runner_do.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import ( @@ -24,6 +38,8 @@ func NewTaskRunner(taskName string, task model.Task, taskSupport TaskSupport) (T return NewRaiseTaskRunner(taskName, t, taskSupport.GetWorkflowDef()) case *model.DoTask: return NewDoTaskRunner(t.Do, taskSupport) + case *model.ForTask: + return NewForTaskRunner(taskName, t, taskSupport) default: return nil, fmt.Errorf("unsupported task type '%T' for task '%s'", t, taskName) } @@ -54,7 +70,7 @@ func (d *DoTaskRunner) GetTaskName() string { // executeTasks runs all defined tasks sequentially. func (d *DoTaskRunner) executeTasks(input interface{}, tasks *model.TaskList) (output interface{}, err error) { - output = deepCloneValue(input) + output = input if tasks == nil { return output, nil } @@ -109,7 +125,7 @@ func (d *DoTaskRunner) runTask(input interface{}, runner TaskRunner, task *model taskName := runner.GetTaskName() if task.Input != nil { - if input, err = d.validateAndEvaluateTaskInput(task, input, taskName); err != nil { + if input, err = d.processTaskInput(task, input, taskName); err != nil { return nil, err } } @@ -119,15 +135,15 @@ func (d *DoTaskRunner) runTask(input interface{}, runner TaskRunner, task *model return nil, err } - if output, err = d.validateAndEvaluateTaskOutput(task, output, taskName); err != nil { + if output, err = d.processTaskOutput(task, output, taskName); err != nil { return nil, err } return output, nil } -// validateAndEvaluateTaskInput processes task input validation and transformation. -func (d *DoTaskRunner) validateAndEvaluateTaskInput(task *model.TaskBase, taskInput interface{}, taskName string) (output interface{}, err error) { +// processTaskInput processes task input validation and transformation. +func (d *DoTaskRunner) processTaskInput(task *model.TaskBase, taskInput interface{}, taskName string) (output interface{}, err error) { if task.Input == nil { return taskInput, nil } @@ -143,8 +159,8 @@ func (d *DoTaskRunner) validateAndEvaluateTaskInput(task *model.TaskBase, taskIn return output, nil } -// validateAndEvaluateTaskOutput processes task output validation and transformation. -func (d *DoTaskRunner) validateAndEvaluateTaskOutput(task *model.TaskBase, taskOutput interface{}, taskName string) (output interface{}, err error) { +// processTaskOutput processes task output validation and transformation. +func (d *DoTaskRunner) processTaskOutput(task *model.TaskBase, taskOutput interface{}, taskName string) (output interface{}, err error) { if task.Output == nil { return taskOutput, nil } diff --git a/impl/task_runner_raise_test.go b/impl/task_runner_raise_test.go index 57d9821..54b6a35 100644 --- a/impl/task_runner_raise_test.go +++ b/impl/task_runner_raise_test.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import ( diff --git a/impl/task_runner_test.go b/impl/task_runner_test.go index b6da3bf..19ac3e6 100644 --- a/impl/task_runner_test.go +++ b/impl/task_runner_test.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import ( diff --git a/impl/task_set_test.go b/impl/task_set_test.go index 09dfe1d..93b380a 100644 --- a/impl/task_set_test.go +++ b/impl/task_set_test.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import ( diff --git a/impl/testdata/chained_set_tasks.yaml b/impl/testdata/chained_set_tasks.yaml index b1388dd..8ee9a9c 100644 --- a/impl/testdata/chained_set_tasks.yaml +++ b/impl/testdata/chained_set_tasks.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: chained-workflow dsl: '1.0.0-alpha5' diff --git a/impl/testdata/concatenating_strings.yaml b/impl/testdata/concatenating_strings.yaml index a0b3a84..22cd1b2 100644 --- a/impl/testdata/concatenating_strings.yaml +++ b/impl/testdata/concatenating_strings.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: concatenating-strings dsl: '1.0.0-alpha5' diff --git a/impl/testdata/conditional_logic.yaml b/impl/testdata/conditional_logic.yaml index dfff8f8..30135a5 100644 --- a/impl/testdata/conditional_logic.yaml +++ b/impl/testdata/conditional_logic.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: conditional-logic dsl: '1.0.0-alpha5' diff --git a/impl/testdata/conditional_logic_input_from.yaml b/impl/testdata/conditional_logic_input_from.yaml index 1b51f04..f64f3e8 100644 --- a/impl/testdata/conditional_logic_input_from.yaml +++ b/impl/testdata/conditional_logic_input_from.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: conditional-logic dsl: '1.0.0-alpha5' diff --git a/impl/testdata/raise_conditional.yaml b/impl/testdata/raise_conditional.yaml index 9a74d70..2d9f809 100644 --- a/impl/testdata/raise_conditional.yaml +++ b/impl/testdata/raise_conditional.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # $schema: https://raw.githubusercontent.com/serverlessworkflow/specification/refs/heads/main/schema/workflow.yaml document: dsl: '1.0.0-alpha5' diff --git a/impl/testdata/raise_error_with_input.yaml b/impl/testdata/raise_error_with_input.yaml index 9fe44cd..96affe1 100644 --- a/impl/testdata/raise_error_with_input.yaml +++ b/impl/testdata/raise_error_with_input.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: dsl: '1.0.0-alpha5' namespace: test diff --git a/impl/testdata/raise_inline.yaml b/impl/testdata/raise_inline.yaml index 639a778..c464877 100644 --- a/impl/testdata/raise_inline.yaml +++ b/impl/testdata/raise_inline.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: dsl: '1.0.0-alpha5' namespace: test diff --git a/impl/testdata/raise_reusable.yaml b/impl/testdata/raise_reusable.yaml index c4858ca..33a203d 100644 --- a/impl/testdata/raise_reusable.yaml +++ b/impl/testdata/raise_reusable.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: dsl: '1.0.0-alpha5' namespace: test diff --git a/impl/testdata/raise_undefined_reference.yaml b/impl/testdata/raise_undefined_reference.yaml index 7d135d2..1316818 100644 --- a/impl/testdata/raise_undefined_reference.yaml +++ b/impl/testdata/raise_undefined_reference.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: dsl: '1.0.0-alpha5' namespace: test diff --git a/impl/testdata/sequential_set_colors.yaml b/impl/testdata/sequential_set_colors.yaml index 82274f3..b956c71 100644 --- a/impl/testdata/sequential_set_colors.yaml +++ b/impl/testdata/sequential_set_colors.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: dsl: '1.0.0-alpha5' namespace: default diff --git a/impl/testdata/sequential_set_colors_output_as.yaml b/impl/testdata/sequential_set_colors_output_as.yaml index 06e7b24..53c4919 100644 --- a/impl/testdata/sequential_set_colors_output_as.yaml +++ b/impl/testdata/sequential_set_colors_output_as.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: dsl: '1.0.0-alpha5' namespace: default diff --git a/impl/testdata/set_tasks_invalid_then.yaml b/impl/testdata/set_tasks_invalid_then.yaml index e814b76..325c0c2 100644 --- a/impl/testdata/set_tasks_invalid_then.yaml +++ b/impl/testdata/set_tasks_invalid_then.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: invalid-then-workflow dsl: '1.0.0-alpha5' diff --git a/impl/testdata/set_tasks_with_termination.yaml b/impl/testdata/set_tasks_with_termination.yaml index 3e663f0..3c819bd 100644 --- a/impl/testdata/set_tasks_with_termination.yaml +++ b/impl/testdata/set_tasks_with_termination.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: termination-workflow dsl: '1.0.0-alpha5' diff --git a/impl/testdata/set_tasks_with_then.yaml b/impl/testdata/set_tasks_with_then.yaml index 59c6717..e0f8155 100644 --- a/impl/testdata/set_tasks_with_then.yaml +++ b/impl/testdata/set_tasks_with_then.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: then-workflow dsl: '1.0.0-alpha5' diff --git a/impl/testdata/task_export_schema.yaml b/impl/testdata/task_export_schema.yaml index 6148751..e63e869 100644 --- a/impl/testdata/task_export_schema.yaml +++ b/impl/testdata/task_export_schema.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: task-export-schema dsl: '1.0.0-alpha5' diff --git a/impl/testdata/task_input_schema.yaml b/impl/testdata/task_input_schema.yaml index 898e244..d93b574 100644 --- a/impl/testdata/task_input_schema.yaml +++ b/impl/testdata/task_input_schema.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: task-input-schema dsl: '1.0.0-alpha5' diff --git a/impl/testdata/task_output_schema.yaml b/impl/testdata/task_output_schema.yaml index 16c855f..73d784b 100644 --- a/impl/testdata/task_output_schema.yaml +++ b/impl/testdata/task_output_schema.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: task-output-schema dsl: '1.0.0-alpha5' diff --git a/impl/testdata/task_output_schema_with_dynamic_value.yaml b/impl/testdata/task_output_schema_with_dynamic_value.yaml index 5efaf91..39a7df9 100644 --- a/impl/testdata/task_output_schema_with_dynamic_value.yaml +++ b/impl/testdata/task_output_schema_with_dynamic_value.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: task-output-schema-with-dynamic-value dsl: '1.0.0-alpha5' diff --git a/impl/testdata/workflow_input_schema.yaml b/impl/testdata/workflow_input_schema.yaml index a7be0d2..fabf484 100644 --- a/impl/testdata/workflow_input_schema.yaml +++ b/impl/testdata/workflow_input_schema.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: name: workflow-input-schema dsl: '1.0.0-alpha5' diff --git a/impl/utils.go b/impl/utils.go index b3d5cec..2cdf952 100644 --- a/impl/utils.go +++ b/impl/utils.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import ( @@ -47,3 +61,21 @@ func traverseAndEvaluate(runtimeExpr *model.ObjectOrRuntimeExpr, input interface } return output, nil } + +func processIO(data interface{}, schema *model.Schema, transformation *model.ObjectOrRuntimeExpr, taskName string) (interface{}, error) { + if schema != nil { + if err := validateSchema(data, schema, taskName); err != nil { + return nil, err + } + } + + if transformation != nil { + transformed, err := traverseAndEvaluate(transformation, data, taskName) + if err != nil { + return nil, err + } + return transformed, nil + } + + return data, nil +} diff --git a/model/errors.go b/model/errors.go index aad13ff..5f232b9 100644 --- a/model/errors.go +++ b/model/errors.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package model import ( diff --git a/model/errors_test.go b/model/errors_test.go index 6bf26e6..f6fbc45 100644 --- a/model/errors_test.go +++ b/model/errors_test.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package model import ( From 731d02e02c254ddc1c6eb4a03eca85a6b3c528f8 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Fri, 14 Feb 2025 16:04:33 -0500 Subject: [PATCH 07/11] Fix lint Signed-off-by: Ricardo Zanini --- expr/expr.go | 3 ++- impl/json_schema.go | 1 + impl/runner.go | 1 + impl/task_runner.go | 1 + impl/task_runner_do.go | 1 + impl/task_runner_raise_test.go | 39 +++++++++++++++++++++------------- impl/task_runner_test.go | 7 +++--- impl/task_set_test.go | 5 +++-- model/errors.go | 4 ++-- model/errors_test.go | 3 ++- model/runtime_expression.go | 1 + model/task_for_test.go | 3 ++- model/validator.go | 3 ++- parser/cmd/main.go | 3 ++- 14 files changed, 48 insertions(+), 27 deletions(-) diff --git a/expr/expr.go b/expr/expr.go index 247eb3e..0cfb277 100644 --- a/expr/expr.go +++ b/expr/expr.go @@ -17,8 +17,9 @@ package expr import ( "errors" "fmt" - "github.com/itchyny/gojq" "strings" + + "github.com/itchyny/gojq" ) // IsStrictExpr returns true if the string is enclosed in `${ }` diff --git a/impl/json_schema.go b/impl/json_schema.go index 5996606..396f9f5 100644 --- a/impl/json_schema.go +++ b/impl/json_schema.go @@ -18,6 +18,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/xeipuuv/gojsonschema" ) diff --git a/impl/runner.go b/impl/runner.go index 5d8583c..c219886 100644 --- a/impl/runner.go +++ b/impl/runner.go @@ -17,6 +17,7 @@ package impl import ( "context" "fmt" + "github.com/serverlessworkflow/sdk-go/v3/model" ) diff --git a/impl/task_runner.go b/impl/task_runner.go index e346db2..e9f1724 100644 --- a/impl/task_runner.go +++ b/impl/task_runner.go @@ -16,6 +16,7 @@ package impl import ( "fmt" + "github.com/serverlessworkflow/sdk-go/v3/expr" "github.com/serverlessworkflow/sdk-go/v3/model" ) diff --git a/impl/task_runner_do.go b/impl/task_runner_do.go index 5426789..a34a4dd 100644 --- a/impl/task_runner_do.go +++ b/impl/task_runner_do.go @@ -16,6 +16,7 @@ package impl import ( "fmt" + "github.com/serverlessworkflow/sdk-go/v3/expr" "github.com/serverlessworkflow/sdk-go/v3/model" ) diff --git a/impl/task_runner_raise_test.go b/impl/task_runner_raise_test.go index 54b6a35..3527283 100644 --- a/impl/task_runner_raise_test.go +++ b/impl/task_runner_raise_test.go @@ -17,9 +17,10 @@ package impl import ( "encoding/json" "errors" + "testing" + "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/stretchr/testify/assert" - "testing" ) func TestRaiseTaskRunner_WithDefinedError(t *testing.T) { @@ -47,16 +48,20 @@ func TestRaiseTaskRunner_WithDefinedError(t *testing.T) { expectedErr := model.NewErrValidation(errors.New("Invalid input data"), "task_raise_defined") - assert.Equal(t, expectedErr.Type.String(), err.(*model.Error).Type.String()) - assert.Equal(t, expectedErr.Status, err.(*model.Error).Status) - assert.Equal(t, expectedErr.Title.String(), err.(*model.Error).Title.String()) - assert.Equal(t, "Invalid input data", err.(*model.Error).Detail.String()) - assert.Equal(t, expectedErr.Instance.String(), err.(*model.Error).Instance.String()) + var modelErr *model.Error + if errors.As(err, &modelErr) { + assert.Equal(t, expectedErr.Type.String(), modelErr.Type.String()) + assert.Equal(t, expectedErr.Status, modelErr.Status) + assert.Equal(t, expectedErr.Title.String(), modelErr.Title.String()) + assert.Equal(t, "Invalid input data", modelErr.Detail.String()) + assert.Equal(t, expectedErr.Instance.String(), modelErr.Instance.String()) + } else { + t.Errorf("expected error of type *model.Error but got %T", err) + } } func TestRaiseTaskRunner_WithReferencedError(t *testing.T) { - var ref string - ref = "someErrorRef" + ref := "someErrorRef" raiseTask := &model.RaiseTask{ Raise: model.RaiseTaskConfiguration{ Error: model.RaiseTaskError{ @@ -97,11 +102,16 @@ func TestRaiseTaskRunner_TimeoutErrorWithExpression(t *testing.T) { expectedErr := model.NewErrTimeout(errors.New("Request took too long"), "task_raise_timeout_expr") - assert.Equal(t, expectedErr.Type.String(), err.(*model.Error).Type.String()) - assert.Equal(t, expectedErr.Status, err.(*model.Error).Status) - assert.Equal(t, expectedErr.Title.String(), err.(*model.Error).Title.String()) - assert.Equal(t, "Request took too long", err.(*model.Error).Detail.String()) - assert.Equal(t, expectedErr.Instance.String(), err.(*model.Error).Instance.String()) + var modelErr *model.Error + if errors.As(err, &modelErr) { + assert.Equal(t, expectedErr.Type.String(), modelErr.Type.String()) + assert.Equal(t, expectedErr.Status, modelErr.Status) + assert.Equal(t, expectedErr.Title.String(), modelErr.Title.String()) + assert.Equal(t, "Request took too long", modelErr.Detail.String()) + assert.Equal(t, expectedErr.Instance.String(), modelErr.Instance.String()) + } else { + t.Errorf("expected error of type *model.Error but got %T", err) + } } func TestRaiseTaskRunner_Serialization(t *testing.T) { @@ -134,8 +144,7 @@ func TestRaiseTaskRunner_Serialization(t *testing.T) { } func TestRaiseTaskRunner_ReferenceSerialization(t *testing.T) { - var ref string - ref = "errorReference" + ref := "errorReference" raiseTask := &model.RaiseTask{ Raise: model.RaiseTaskConfiguration{ Error: model.RaiseTaskError{ diff --git a/impl/task_runner_test.go b/impl/task_runner_test.go index 19ac3e6..887c22e 100644 --- a/impl/task_runner_test.go +++ b/impl/task_runner_test.go @@ -15,12 +15,13 @@ package impl import ( - "github.com/serverlessworkflow/sdk-go/v3/model" - "github.com/serverlessworkflow/sdk-go/v3/parser" - "github.com/stretchr/testify/assert" "os" "path/filepath" "testing" + + "github.com/serverlessworkflow/sdk-go/v3/model" + "github.com/serverlessworkflow/sdk-go/v3/parser" + "github.com/stretchr/testify/assert" ) // runWorkflowTest is a reusable test function for workflows diff --git a/impl/task_set_test.go b/impl/task_set_test.go index 93b380a..48ca18b 100644 --- a/impl/task_set_test.go +++ b/impl/task_set_test.go @@ -15,10 +15,11 @@ package impl import ( - "github.com/serverlessworkflow/sdk-go/v3/model" - "github.com/stretchr/testify/assert" "reflect" "testing" + + "github.com/serverlessworkflow/sdk-go/v3/model" + "github.com/stretchr/testify/assert" ) func TestSetTaskExecutor_Exec(t *testing.T) { diff --git a/model/errors.go b/model/errors.go index 5f232b9..eeef71c 100644 --- a/model/errors.go +++ b/model/errors.go @@ -297,13 +297,13 @@ func GenerateJSONPointer(workflow *Workflow, targetNode interface{}) (string, er // Convert struct to JSON jsonData, err := json.Marshal(workflow) if err != nil { - return "", fmt.Errorf("error marshalling to JSON: %v", err) + return "", fmt.Errorf("error marshalling to JSON: %w", err) } // Convert JSON to a generic map for traversal var jsonMap map[string]interface{} if err := json.Unmarshal(jsonData, &jsonMap); err != nil { - return "", fmt.Errorf("error unmarshalling JSON: %v", err) + return "", fmt.Errorf("error unmarshalling JSON: %w", err) } transformedNode := "" diff --git a/model/errors_test.go b/model/errors_test.go index f6fbc45..12a00fb 100644 --- a/model/errors_test.go +++ b/model/errors_test.go @@ -15,8 +15,9 @@ package model import ( - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" ) // TestGenerateJSONPointer_SimpleTask tests a simple workflow task. diff --git a/model/runtime_expression.go b/model/runtime_expression.go index 68c21ac..6a056cb 100644 --- a/model/runtime_expression.go +++ b/model/runtime_expression.go @@ -17,6 +17,7 @@ package model import ( "encoding/json" "fmt" + "github.com/serverlessworkflow/sdk-go/v3/expr" ) diff --git a/model/task_for_test.go b/model/task_for_test.go index e24bf3b..3d8fc37 100644 --- a/model/task_for_test.go +++ b/model/task_for_test.go @@ -16,9 +16,10 @@ package model import ( "encoding/json" - "sigs.k8s.io/yaml" "testing" + "sigs.k8s.io/yaml" + "github.com/stretchr/testify/assert" ) diff --git a/model/validator.go b/model/validator.go index 91c34b9..12ec1a1 100644 --- a/model/validator.go +++ b/model/validator.go @@ -17,9 +17,10 @@ package model import ( "errors" "fmt" - "github.com/go-playground/validator/v10" "regexp" "strings" + + "github.com/go-playground/validator/v10" ) var ( diff --git a/parser/cmd/main.go b/parser/cmd/main.go index e811696..b90b902 100644 --- a/parser/cmd/main.go +++ b/parser/cmd/main.go @@ -16,9 +16,10 @@ package main import ( "fmt" - "github.com/serverlessworkflow/sdk-go/v3/parser" "os" "path/filepath" + + "github.com/serverlessworkflow/sdk-go/v3/parser" ) func main() { From e8137051bd418bce5bd325a1d9096ac58981299b Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Thu, 13 Mar 2025 17:24:17 -0400 Subject: [PATCH 08/11] Add partial 'For' implementation Signed-off-by: Ricardo Zanini --- expr/expr.go | 2 + impl/task_runner.go | 76 ++++++++++++++++++++++++++++++++++- impl/task_runner_test.go | 17 ++++++++ impl/testdata/for_colors.yaml | 14 +++++++ model/task.go | 7 +++- 5 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 impl/testdata/for_colors.yaml diff --git a/expr/expr.go b/expr/expr.go index 0cfb277..cd5a755 100644 --- a/expr/expr.go +++ b/expr/expr.go @@ -86,6 +86,8 @@ func TraverseAndEvaluate(node interface{}, input interface{}) (interface{}, erro } } +// TODO: add support to variables see https://github.com/itchyny/gojq/blob/main/option_variables_test.go + // evaluateJQExpression evaluates a jq expression against a given JSON input func evaluateJQExpression(expression string, input interface{}) (interface{}, error) { // Parse the sanitized jq expression diff --git a/impl/task_runner.go b/impl/task_runner.go index e9f1724..05d3817 100644 --- a/impl/task_runner.go +++ b/impl/task_runner.go @@ -16,6 +16,8 @@ package impl import ( "fmt" + "reflect" + "strings" "github.com/serverlessworkflow/sdk-go/v3/expr" "github.com/serverlessworkflow/sdk-go/v3/model" @@ -163,6 +165,11 @@ func NewForTaskRunner(taskName string, task *model.ForTask, taskSupport TaskSupp }, nil } +const ( + forTaskDefaultEach = "$item" + forTaskDefaultAt = "$index" +) + type ForTaskRunner struct { Task *model.ForTask TaskName string @@ -170,7 +177,74 @@ type ForTaskRunner struct { } func (f *ForTaskRunner) Run(input interface{}) (interface{}, error) { - return input, nil + f.sanitizeFor() + in, err := expr.TraverseAndEvaluate(f.Task.For.In, input) + if err != nil { + return nil, err + } + + var forOutput interface{} + rv := reflect.ValueOf(in) + switch rv.Kind() { + case reflect.Slice, reflect.Array: + for i := 0; i < rv.Len(); i++ { + item := rv.Index(i).Interface() + + if forOutput, err = f.processForItem(i, item, forOutput); err != nil { + return nil, err + } + } + case reflect.Invalid: + return input, nil + default: + if forOutput, err = f.processForItem(0, in, forOutput); err != nil { + return nil, err + } + } + + return forOutput, nil +} + +func (f *ForTaskRunner) processForItem(idx int, item interface{}, forOutput interface{}) (interface{}, error) { + forInput := map[string]interface{}{ + f.Task.For.At: idx, + f.Task.For.Each: item, + } + if forOutput != nil { + if outputMap, ok := forOutput.(map[string]interface{}); ok { + for key, value := range outputMap { + forInput[key] = value + } + } else { + return nil, fmt.Errorf("task %s item %s at index %d returned a non-json object, impossible to merge context", f.TaskName, f.Task.For.Each, idx) + } + } + var err error + forOutput, err = f.DoRunner.Run(forInput) + if err != nil { + return nil, err + } + + return forOutput, nil +} + +func (f *ForTaskRunner) sanitizeFor() { + f.Task.For.Each = strings.TrimSpace(f.Task.For.Each) + f.Task.For.At = strings.TrimSpace(f.Task.For.At) + + if f.Task.For.Each == "" { + f.Task.For.Each = forTaskDefaultEach + } + if f.Task.For.At == "" { + f.Task.For.At = forTaskDefaultAt + } + + if !strings.HasPrefix(f.Task.For.Each, "$") { + f.Task.For.Each = "$" + f.Task.For.Each + } + if !strings.HasPrefix(f.Task.For.At, "$") { + f.Task.For.At = "$" + f.Task.For.At + } } func (f *ForTaskRunner) GetTaskName() string { diff --git a/impl/task_runner_test.go b/impl/task_runner_test.go index 887c22e..c5a76d7 100644 --- a/impl/task_runner_test.go +++ b/impl/task_runner_test.go @@ -309,5 +309,22 @@ func TestWorkflowRunner_Run_YAML_RaiseTasks_ControlFlow(t *testing.T) { assert.Equal(t, "User is under the required age", model.AsError(err).Detail.String()) }) }) +} + +func TestForTaskRunner_Run(t *testing.T) { + t.Skip("Skipping until the For task is implemented - missing JQ variables implementation") + t.Run("Simple For with Colors", func(t *testing.T) { + workflowPath := "./testdata/for_colors.yaml" + input := map[string]interface{}{ + "colors": []string{"red", "green", "blue"}, + } + expectedOutput := map[string]interface{}{ + "processed": map[string]interface{}{ + "colors": []string{"red", "green", "blue"}, + "indexed": []float64{0, 1, 2}, + }, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) } diff --git a/impl/testdata/for_colors.yaml b/impl/testdata/for_colors.yaml new file mode 100644 index 0000000..a6401d8 --- /dev/null +++ b/impl/testdata/for_colors.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.0' + namespace: default + name: for + version: '1.0.0' +do: + - loopColors: + for: + each: color + in: '${ .colors }' + do: + - markProcessed: + set: + processed: '${ { colors: (.processed.colors + [ $color ]), indexes: (.processed.indexes + [ $index ])} }' diff --git a/model/task.go b/model/task.go index 8769e63..4edbd40 100644 --- a/model/task.go +++ b/model/task.go @@ -143,6 +143,8 @@ func unmarshalTask(key string, taskRaw json.RawMessage) (Task, error) { return nil, fmt.Errorf("failed to parse task type for key '%s': %w", key, err) } + // TODO: not the most elegant; can be improved in a smarter way + // Determine task type var task Task if callValue, hasCall := taskType["call"].(string); hasCall { @@ -154,8 +156,11 @@ func unmarshalTask(key string, taskRaw json.RawMessage) (Task, error) { // Default to CallFunction for unrecognized call values task = &CallFunction{} } + } else if _, hasFor := taskType["for"]; hasFor { + // Handle special case "for" that also has "do" + task = taskTypeRegistry["for"]() } else { - // Handle non-call tasks (e.g., "do", "fork") + // Handle everything else (e.g., "do", "fork") for typeKey := range taskType { if constructor, exists := taskTypeRegistry[typeKey]; exists { task = constructor() From 86c62a9bb75a389914a6d7f2a49100a01ef34c68 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Thu, 13 Mar 2025 18:16:47 -0400 Subject: [PATCH 09/11] Add implementation docs Signed-off-by: Ricardo Zanini --- README.md | 223 +++++++++++++++++++++++++++++------------------------- 1 file changed, 122 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 786333e..a13e826 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Go SDK for Serverless Workflow -The Go SDK for Serverless Workflow provides the [specification types](https://github.com/serverlessworkflow/specification/blob/v1.0.0-alpha5/schema/workflow.yaml) defined by the Serverless Workflow DSL in Go, making it easy to parse, validate, and interact with workflows. +The Go SDK for Serverless Workflow provides strongly-typed structures for the [Serverless Workflow specification](https://github.com/serverlessworkflow/specification/blob/v1.0.0/schema/workflow.yaml). It simplifies parsing, validating, and interacting with workflows in Go. Starting from version `v3.1.0`, the SDK also includes a partial reference implementation, allowing users to execute workflows directly within their Go applications. --- @@ -10,8 +10,11 @@ The Go SDK for Serverless Workflow provides the [specification types](https://gi - [Releases](#releases) - [Getting Started](#getting-started) - [Installation](#installation) + - [Basic Usage](#basic-usage) - [Parsing Workflow Files](#parsing-workflow-files) - [Programmatic Workflow Creation](#programmatic-workflow-creation) +- [Reference Implementation](#reference-implementation) + - [Example: Running a Workflow](#example-running-a-workflow) - [Slack Community](#slack-community) - [Contributing](#contributing) - [Code Style](#code-style) @@ -22,160 +25,178 @@ The Go SDK for Serverless Workflow provides the [specification types](https://gi ## Status -The current status of features implemented in the SDK is listed below: +This table indicates the current state of implementation of various SDK features: -| Feature | Status | -|-------------------------------------------- | ------------------ | -| Parse workflow JSON and YAML definitions | :heavy_check_mark: | -| Programmatically build workflow definitions | :heavy_check_mark: | -| Validate workflow definitions (Schema) | :heavy_check_mark: | -| Validate workflow definitions (Integrity) | :no_entry_sign: | -| Generate workflow diagram (SVG) | :no_entry_sign: | +| Feature | Status | +|-------------------------------------------- |---------------------| +| Parse workflow JSON and YAML definitions | :heavy_check_mark: | +| Programmatically build workflow definitions | :heavy_check_mark: | +| Validate workflow definitions (Schema) | :heavy_check_mark: | +| Specification Implementation | :heavy_check_mark:* | +| Validate workflow definitions (Integrity) | :no_entry_sign: | +| Generate workflow diagram (SVG) | :no_entry_sign: | ---- - -## Releases - -| Latest Releases | Conformance to Spec Version | -|:--------------------------------------------------------------------------:|:------------------------------------------------------------------------:| -| [v1.0.0](https://github.com/serverlessworkflow/sdk-go/releases/tag/v1.0.0) | [v0.5](https://github.com/serverlessworkflow/specification/tree/0.5.x) | -| [v2.0.1](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.0.1) | [v0.6](https://github.com/serverlessworkflow/specification/tree/0.6.x) | -| [v2.1.2](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.1.2) | [v0.7](https://github.com/serverlessworkflow/specification/tree/0.7.x) | -| [v2.4.3](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.4.1) | [v0.8](https://github.com/serverlessworkflow/specification/tree/0.8.x) | -| [v3.0.0](https://github.com/serverlessworkflow/sdk-go/releases/tag/v3.0.0) | [v1.0.0](https://github.com/serverlessworkflow/specification/releases/tag/v1.0.0-alpha5) | +> **Note**: *Implementation is partial; contributions are encouraged. --- -## Getting Started - -### Installation +## Reference Implementation -To use the SDK in your Go project, run the following command: - -```shell -$ go get github.com/serverlessworkflow/sdk-go/v3 -``` +The SDK provides a partial reference runner to execute your workflows: -This will update your `go.mod` file to include the Serverless Workflow SDK as a dependency. +### Example: Running a Workflow -Import the SDK in your Go file: +Below is a simple YAML workflow that sets a message and then prints it: -```go -import "github.com/serverlessworkflow/sdk-go/v3/model" +```yaml +document: + dsl: "1.0.0" + namespace: "examples" + name: "simple-workflow" + version: "1.0.0" +do: + - set: + message: "Hello from the Serverless Workflow SDK in Go!" ``` -You can now use the SDK types and functions, for example: - -```go -package main +You can execute this workflow using the following Go program: -import ( - "github.com/serverlessworkflow/sdk-go/v3/builder" - "github.com/serverlessworkflow/sdk-go/v3/model" -) - -func main() { - workflowBuilder := New(). - SetDocument("1.0.0", "examples", "example-workflow", "1.0.0"). - AddTask("task1", &model.CallHTTP{ - TaskBase: model.TaskBase{ - If: &model.RuntimeExpression{Value: "${condition}"}, - }, - Call: "http", - With: model.HTTPArguments{ - Method: "GET", - Endpoint: model.NewEndpoint("http://example.com"), - }, - }) - workflow, _ := builder.Object(workflowBuilder) - // use your models -} - -``` - -### Parsing Workflow Files - -The Serverless Workflow Specification supports YAML and JSON files. Use the following example to parse a workflow file into a Go data structure: +Example of executing a workflow defined in YAML: ```go package main import ( - "github.com/serverlessworkflow/sdk-go/v3/model" + "fmt" + "os" + "path/filepath" + + "github.com/serverlessworkflow/sdk-go/v3/impl" "github.com/serverlessworkflow/sdk-go/v3/parser" ) -func ParseWorkflow(filePath string) (*model.Workflow, error) { - workflow, err := parser.FromFile(filePath) +func RunWorkflow(workflowFilePath string, input map[string]interface{}) (interface{}, error) { + data, err := os.ReadFile(filepath.Clean(workflowFilePath)) + if err != nil { + return nil, err + } + workflow, err := parser.FromYAMLSource(data) if err != nil { return nil, err } - return workflow, nil -} -``` -This `Workflow` structure can then be used programmatically in your application. + runner := impl.NewDefaultRunner(workflow) + output, err := runner.Run(input) + if err != nil { + return nil, err + } + return output, nil +} -### Programmatic Workflow Creation +func main() { + output, err := RunWorkflow("./myworkflow.yaml", map[string]interface{}{"shouldCall": true}) + if err != nil { + panic(err) + } + fmt.Printf("Workflow completed with output: %v\n", output) +} +``` -Support for building workflows programmatically is planned for future releases. Stay tuned for updates in upcoming versions. +### Implementation Roadmap + +The table below lists the current state of this implementation. This table is a roadmap for the project based on the [DSL Reference doc](https://github.com/serverlessworkflow/specification/blob/v1.0.0/dsl-reference.md). + +| Feature | State | +| ----------- | --------------- | +| Workflow Document | ✅ | +| Workflow Use | 🟡 | +| Workflow Schedule | ❌ | +| Task Call | ❌ | +| Task Do | ✅ | +| Task Emit | ❌ | +| Task For | ❌ | +| Task Fork | ❌ | +| Task Listen | ❌ | +| Task Raise | ✅ | +| Task Run | ❌ | +| Task Set | ✅ | +| Task Switch | ❌ | +| Task Try | ❌ | +| Task Wait | ❌ | +| Lifecycle Events | 🟡 | +| External Resource | ❌ | +| Authentication | ❌ | +| Catalog | ❌ | +| Extension | ❌ | +| Error | ✅ | +| Event Consumption Strategies | ❌ | +| Retry | ❌ | +| Input | ✅ | +| Output | ✅ | +| Export | ✅ | +| Timeout | ❌ | +| Duration | ❌ | +| Endpoint | ✅ | +| HTTP Response | ❌ | +| HTTP Request | ❌ | +| URI Template | ✅ | +| Container Lifetime | ❌ | +| Process Result | ❌ | +| AsyncAPI Server | ❌ | +| AsyncAPI Outbound Message | ❌ | +| AsyncAPI Subscription | ❌ | +| Workflow Definition Reference | ❌ | +| Subscription Iterator | ❌ | + +We love contributions! Our aim is to have a complete implementation to serve as a reference or to become a project on its own to favor the CNCF Ecosystem. + +If you are willing to help, please [file a sub-task](https://github.com/serverlessworkflow/sdk-go/issues/221) in this EPIC describing what you are planning to work on first. --- ## Slack Community -Join the conversation and connect with other contributors on the [CNCF Slack](https://communityinviter.com/apps/cloud-native/cncf). Find us in the `#serverless-workflow-sdk` channel and say hello! 🙋 +Join our community on the CNCF Slack to collaborate, ask questions, and contribute: + +[CNCF Slack Invite](https://communityinviter.com/apps/cloud-native/cncf) + +Find us in the `#serverless-workflow-sdk` channel. --- ## Contributing -We welcome contributions to improve this SDK. Please refer to the sections below for guidance on maintaining project standards. +Your contributions are very welcome! ### Code Style -- Use `goimports` for import organization. -- Lint your code with: +- Format imports with `goimports`. +- Run static analysis using: -```bash +```shell make lint ``` -To automatically fix lint issues, use: +Automatically fix lint issues: -```bash +```shell make lint params=--fix ``` -Example lint error: - -```bash -$ make lint -make addheaders -make fmt -./hack/go-lint.sh -util/floatstr/floatstr_test.go:19: File is not `goimports`-ed (goimports) - "k8s.io/apimachinery/pkg/util/yaml" -make: *** [lint] Error 1 -``` - ### EditorConfig -For IntelliJ users, an example `.editorconfig` file is available [here](contrib/intellij.editorconfig). See the [Jetbrains documentation](https://www.jetbrains.com/help/idea/editorconfig.html) for usage details. +A sample `.editorconfig` for IntelliJ or GoLand users can be found [here](contrib/intellij.editorconfig). ### Known Issues -#### MacOS Issue: - -On MacOS, you might encounter the following error: +- **MacOS Issue**: If you encounter `goimports: can't extract issues from gofmt diff output`, resolve it with: -``` -goimports: can't extract issues from gofmt diff output +```shell +brew install diffutils ``` -To resolve this, install `diffutils`: +--- -```bash -brew install diffutils -``` +Contributions are greatly appreciated! Check [this EPIC](https://github.com/serverlessworkflow/sdk-go/issues/221) and contribute to completing more features. +Happy coding! From c2c84ac04e82daabeecb783d487486b3ed0ea249 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Thu, 13 Mar 2025 18:28:54 -0400 Subject: [PATCH 10/11] Solve lint issues Signed-off-by: Ricardo Zanini --- builder/builder_test.go | 6 +++--- impl/testdata/for_colors.yaml | 14 ++++++++++++++ model/extension_test.go | 2 +- model/task_test.go | 2 +- model/validator.go | 2 +- model/workflow_test.go | 2 +- 6 files changed, 21 insertions(+), 7 deletions(-) diff --git a/builder/builder_test.go b/builder/builder_test.go index cbec324..6bf459c 100644 --- a/builder/builder_test.go +++ b/builder/builder_test.go @@ -18,7 +18,7 @@ import ( "errors" "testing" - "github.com/go-playground/validator/v10" + validator "github.com/go-playground/validator/v10" "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/serverlessworkflow/sdk-go/v3/test" @@ -137,7 +137,7 @@ func TestBuilder_Validate(t *testing.T) { Version: "1.0.0", }, Do: &model.TaskList{ - { + &model.TaskItem{ Key: "task1", Task: &model.CallHTTP{ Call: "http", @@ -155,7 +155,7 @@ func TestBuilder_Validate(t *testing.T) { // Test validation failure workflow.Do = &model.TaskList{ - { + &model.TaskItem{ Key: "task2", Task: &model.CallHTTP{ Call: "http", diff --git a/impl/testdata/for_colors.yaml b/impl/testdata/for_colors.yaml index a6401d8..ac33620 100644 --- a/impl/testdata/for_colors.yaml +++ b/impl/testdata/for_colors.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + document: dsl: '1.0.0' namespace: default diff --git a/model/extension_test.go b/model/extension_test.go index 7a11a5f..f258a4c 100644 --- a/model/extension_test.go +++ b/model/extension_test.go @@ -19,7 +19,7 @@ import ( "errors" "testing" - "github.com/go-playground/validator/v10" + validator "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" ) diff --git a/model/task_test.go b/model/task_test.go index c3d869c..fdd07cf 100644 --- a/model/task_test.go +++ b/model/task_test.go @@ -19,7 +19,7 @@ import ( "errors" "testing" - "github.com/go-playground/validator/v10" + validator "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" ) diff --git a/model/validator.go b/model/validator.go index 12ec1a1..60b87b8 100644 --- a/model/validator.go +++ b/model/validator.go @@ -20,7 +20,7 @@ import ( "regexp" "strings" - "github.com/go-playground/validator/v10" + validator "github.com/go-playground/validator/v10" ) var ( diff --git a/model/workflow_test.go b/model/workflow_test.go index 4a620af..c88de64 100644 --- a/model/workflow_test.go +++ b/model/workflow_test.go @@ -19,7 +19,7 @@ import ( "errors" "testing" - "github.com/go-playground/validator/v10" + validator "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" ) From 2a85d9bff76121f1050593ea6e88d3dba17b4698 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Mon, 24 Mar 2025 18:15:20 -0400 Subject: [PATCH 11/11] Readd releases table to README Signed-off-by: Ricardo Zanini --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index a13e826..9daabf0 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,18 @@ This table indicates the current state of implementation of various SDK features --- +## Releases + +| Latest Releases | Conformance to Spec Version | +|:--------------------------------------------------------------------------:|:---------------------------------------------------------------------------------:| +| [v1.0.0](https://github.com/serverlessworkflow/sdk-go/releases/tag/v1.0.0) | [v0.5](https://github.com/serverlessworkflow/specification/tree/0.5.x) | +| [v2.0.1](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.0.1) | [v0.6](https://github.com/serverlessworkflow/specification/tree/0.6.x) | +| [v2.1.2](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.1.2) | [v0.7](https://github.com/serverlessworkflow/specification/tree/0.7.x) | +| [v2.4.3](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.4.1) | [v0.8](https://github.com/serverlessworkflow/specification/tree/0.8.x) | +| [v3.0.0](https://github.com/serverlessworkflow/sdk-go/releases/tag/v3.0.0) | [v1.0.0](https://github.com/serverlessworkflow/specification/releases/tag/v1.0.0) | + +--- + ## Reference Implementation The SDK provides a partial reference runner to execute your workflows: