From 15e2f105eabb332acab5e509cf9e172adc258743 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:07:01 -0300 Subject: [PATCH 01/11] Fix #222 - Basic raw implementation for DSL 1.0.0 (#224) * Fix #222 - (WIP): Basic raw implementation for DSL 1.0.0 Signed-off-by: Ricardo Zanini * Evaluate expressions, statusphase, schema validation, export, as, from Signed-off-by: Ricardo Zanini * Add raise task Signed-off-by: Ricardo Zanini * Task Do implementation and refactoring Signed-off-by: Ricardo Zanini * Upgrade Upload Artifact Signed-off-by: Ricardo Zanini * Add missing license headers Signed-off-by: Ricardo Zanini * Fix lint Signed-off-by: Ricardo Zanini * Add partial 'For' implementation Signed-off-by: Ricardo Zanini * Add implementation docs Signed-off-by: Ricardo Zanini * Solve lint issues Signed-off-by: Ricardo Zanini * Readd releases table to README Signed-off-by: Ricardo Zanini --------- Signed-off-by: Ricardo Zanini --- .github/workflows/Go-SDK-PR-Check.yaml | 4 +- README.md | 227 ++++++---- builder/builder_test.go | 6 +- expr/expr.go | 112 +++++ go.mod | 3 + go.sum | 10 + impl/context.go | 151 +++++++ impl/json_schema.go | 70 +++ impl/runner.go | 124 ++++++ impl/status_phase.go | 52 +++ impl/task_runner.go | 252 +++++++++++ impl/task_runner_do.go | 178 ++++++++ impl/task_runner_raise_test.go | 165 +++++++ impl/task_runner_test.go | 330 ++++++++++++++ impl/task_set_test.go | 416 ++++++++++++++++++ impl/testdata/chained_set_tasks.yaml | 29 ++ impl/testdata/concatenating_strings.yaml | 31 ++ impl/testdata/conditional_logic.yaml | 26 ++ .../conditional_logic_input_from.yaml | 25 ++ impl/testdata/for_colors.yaml | 28 ++ impl/testdata/raise_conditional.yaml | 32 ++ impl/testdata/raise_error_with_input.yaml | 27 ++ impl/testdata/raise_inline.yaml | 27 ++ impl/testdata/raise_reusable.yaml | 30 ++ impl/testdata/raise_undefined_reference.yaml | 23 + impl/testdata/sequential_set_colors.yaml | 31 ++ .../sequential_set_colors_output_as.yaml | 31 ++ impl/testdata/set_tasks_invalid_then.yaml | 27 ++ impl/testdata/set_tasks_with_termination.yaml | 27 ++ impl/testdata/set_tasks_with_then.yaml | 30 ++ impl/testdata/task_export_schema.yaml | 32 ++ impl/testdata/task_input_schema.yaml | 32 ++ impl/testdata/task_output_schema.yaml | 32 ++ ...task_output_schema_with_dynamic_value.yaml | 32 ++ impl/testdata/workflow_input_schema.yaml | 32 ++ impl/utils.go | 81 ++++ model/endpoint.go | 9 + model/endpoint_test.go | 10 +- model/errors.go | 324 ++++++++++++++ model/errors_test.go | 139 ++++++ model/extension_test.go | 2 +- model/objects.go | 79 ++++ model/runtime_expression.go | 23 +- model/task.go | 138 +++--- model/task_call.go | 20 + model/task_do.go | 4 + model/task_event.go | 8 + model/task_for.go | 4 + model/task_for_test.go | 3 +- model/task_fork.go | 4 + model/task_raise.go | 20 +- model/task_raise_test.go | 8 +- model/task_run.go | 4 + model/task_set.go | 4 + model/task_switch.go | 4 + model/task_test.go | 69 ++- model/task_try.go | 4 + model/task_wait.go | 4 + model/validator.go | 3 +- model/workflow.go | 5 + model/workflow_test.go | 18 +- parser/cmd/main.go | 3 +- 62 files changed, 3448 insertions(+), 230 deletions(-) create mode 100644 expr/expr.go create mode 100644 impl/context.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/task_runner.go create mode 100644 impl/task_runner_do.go create mode 100644 impl/task_runner_raise_test.go create mode 100644 impl/task_runner_test.go create mode 100644 impl/task_set_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/conditional_logic_input_from.yaml create mode 100644 impl/testdata/for_colors.yaml 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/sequential_set_colors.yaml create mode 100644 impl/testdata/sequential_set_colors_output_as.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 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 impl/utils.go create mode 100644 model/errors.go create mode 100644 model/errors_test.go 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 diff --git a/README.md b/README.md index 786333e..9daabf0 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,190 @@ 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: | + +> **Note**: *Implementation is partial; contributions are encouraged. --- ## 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) | +| 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) | --- -## Getting Started - -### Installation - -To use the SDK in your Go project, run the following command: - -```shell -$ go get github.com/serverlessworkflow/sdk-go/v3 -``` - -This will update your `go.mod` file to include the Serverless Workflow SDK as a dependency. - -Import the SDK in your Go file: - -```go -import "github.com/serverlessworkflow/sdk-go/v3/model" -``` - -You can now use the SDK types and functions, for example: +## Reference Implementation -```go -package main +The SDK provides a partial reference runner to execute your workflows: -import ( - "github.com/serverlessworkflow/sdk-go/v3/builder" - "github.com/serverlessworkflow/sdk-go/v3/model" -) +### Example: Running a Workflow -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 -} +Below is a simple YAML workflow that sets a message and then prints it: +```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!" ``` -### Parsing Workflow Files +You can execute this workflow using the following Go program: -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! 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/expr/expr.go b/expr/expr.go new file mode 100644 index 0000000..cd5a755 --- /dev/null +++ b/expr/expr.go @@ -0,0 +1,112 @@ +// 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 ( + "errors" + "fmt" + "strings" + + "github.com/itchyny/gojq" +) + +// 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 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 + } +} + +// 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 + 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/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 new file mode 100644 index 0000000..ae9375e --- /dev/null +++ b/impl/context.go @@ -0,0 +1,151 @@ +// 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" + "errors" + "sync" +) + +type ctxKey string + +const runnerCtxKey ctxKey = "wfRunnerContext" + +// 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 + context map[string]interface{} + StatusPhase []StatusPhaseLog + TasksStatusPhase map[string][]StatusPhaseLog // Holds `$context` as the key +} + +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{} + } + ctx.StatusPhase = append(ctx.StatusPhase, NewStatusPhaseLog(status)) +} + +func (ctx *WorkflowContext) SetTaskStatus(task string, status StatusPhase) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + if ctx.TasksStatusPhase == nil { + ctx.TasksStatusPhase = map[string][]StatusPhaseLog{} + } + ctx.TasksStatusPhase[task] = append(ctx.TasksStatusPhase[task], NewStatusPhaseLog(status)) +} + +// 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{}) + } + ctx.context["$context"] = value +} + +// 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 ctx.context["$context"] +} + +// SetInput safely sets the input +func (ctx *WorkflowContext) SetInput(input interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + ctx.input = input +} + +// GetInput safely retrieves the input +func (ctx *WorkflowContext) GetInput() interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() + return ctx.input +} + +// SetOutput safely sets the output +func (ctx *WorkflowContext) SetOutput(output interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + ctx.output = output +} + +// GetOutput safely retrieves the 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 (ctx *WorkflowContext) GetInputAsMap() map[string]interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + 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{}{ + "": 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 (ctx *WorkflowContext) GetOutputAsMap() map[string]interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + 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{}{ + "": ctx.output, + } +} + +// WithWorkflowContext adds the WorkflowContext to a parent context +func WithWorkflowContext(parent context.Context, wfCtx *WorkflowContext) context.Context { + return context.WithValue(parent, runnerCtxKey, wfCtx) +} + +// 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") + } + return wfCtx, nil +} diff --git a/impl/json_schema.go b/impl/json_schema.go new file mode 100644 index 0000000..396f9f5 --- /dev/null +++ b/impl/json_schema.go @@ -0,0 +1,70 @@ +// 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 ( + "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..c219886 --- /dev/null +++ b/impl/runner.go @@ -0,0 +1,124 @@ +// 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" +) + +var _ WorkflowRunner = &workflowRunnerImpl{} + +type WorkflowRunner interface { + GetWorkflowDef() *model.Workflow + Run(input interface{}) (output interface{}, err error) + GetContext() *WorkflowContext +} + +func NewDefaultRunner(workflow *model.Workflow) WorkflowRunner { + wfContext := &WorkflowContext{} + wfContext.SetStatus(PendingStatus) + // TODO: based on the workflow definition, the context might change. + ctx := WithWorkflowContext(context.Background(), wfContext) + return &workflowRunnerImpl{ + Workflow: workflow, + Context: ctx, + RunnerCtx: wfContext, + } +} + +type workflowRunnerImpl struct { + Workflow *model.Workflow + Context context.Context + RunnerCtx *WorkflowContext +} + +func (wr *workflowRunnerImpl) GetContext() *WorkflowContext { + return wr.RunnerCtx +} + +func (wr *workflowRunnerImpl) GetTaskContext() TaskContext { + 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 + } + + wr.RunnerCtx.SetInput(input) + // Run tasks sequentially + wr.RunnerCtx.SetStatus(RunningStatus) + 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 + } + + // Process output + if output, err = wr.processOutput(output); err != nil { + return nil, err + } + + wr.RunnerCtx.SetOutput(output) + wr.RunnerCtx.SetStatus(CompletedStatus) + 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(fmt.Errorf("workflow '%s', task '%s': %w", wr.Workflow.Document.Name, taskName, err), taskName) +} + +// processInput validates and transforms input if needed. +func (wr *workflowRunnerImpl) processInput(input interface{}) (output interface{}, err error) { + if wr.Workflow.Input != nil { + output, err = processIO(input, wr.Workflow.Input.Schema, wr.Workflow.Input.From, "/") + if err != nil { + return nil, err + } + return output, nil + } + return input, nil +} + +// processOutput applies output transformations. +func (wr *workflowRunnerImpl) processOutput(output interface{}) (interface{}, error) { + if wr.Workflow.Output != nil { + return processIO(output, wr.Workflow.Output.Schema, wr.Workflow.Output.As, "/") + } + return output, nil +} diff --git a/impl/status_phase.go b/impl/status_phase.go new file mode 100644 index 0000000..ca61fad --- /dev/null +++ b/impl/status_phase.go @@ -0,0 +1,52 @@ +// 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" + +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_runner.go b/impl/task_runner.go new file mode 100644 index 0000000..05d3817 --- /dev/null +++ b/impl/task_runner.go @@ -0,0 +1,252 @@ +// 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 ( + "fmt" + "reflect" + "strings" + + "github.com/serverlessworkflow/sdk-go/v3/expr" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +var _ TaskRunner = &SetTaskRunner{} +var _ TaskRunner = &RaiseTaskRunner{} +var _ TaskRunner = &ForTaskRunner{} + +type TaskRunner interface { + Run(input interface{}) (interface{}, error) + GetTaskName() string +} + +func NewSetTaskRunner(taskName string, task *model.SetTask) (*SetTaskRunner, error) { + if task == nil || task.Set == nil { + return nil, model.NewErrValidation(fmt.Errorf("no set configuration provided for SetTask %s", taskName), taskName) + } + return &SetTaskRunner{ + Task: task, + TaskName: taskName, + }, nil +} + +type SetTaskRunner struct { + Task *model.SetTask + TaskName string +} + +func (s *SetTaskRunner) GetTaskName() string { + return s.TaskName +} + +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, model.NewErrExpression(err, s.TaskName) + } + + output, ok := result.(map[string]interface{}) + if !ok { + 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 +} + +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{ + Task: task, + TaskName: taskName, + }, 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 +} + +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 +} + +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 +} + +const ( + forTaskDefaultEach = "$item" + forTaskDefaultAt = "$index" +) + +type ForTaskRunner struct { + Task *model.ForTask + TaskName string + DoRunner *DoTaskRunner +} + +func (f *ForTaskRunner) Run(input interface{}) (interface{}, error) { + 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 { + return f.TaskName +} diff --git a/impl/task_runner_do.go b/impl/task_runner_do.go new file mode 100644 index 0000000..a34a4dd --- /dev/null +++ b/impl/task_runner_do.go @@ -0,0 +1,178 @@ +// 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 ( + "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) + case *model.ForTask: + return NewForTaskRunner(taskName, t, 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 = 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.processTaskInput(task, input, taskName); err != nil { + return nil, err + } + } + + output, err = runner.Run(input) + if err != nil { + return nil, err + } + + if output, err = d.processTaskOutput(task, output, taskName); err != nil { + return nil, err + } + + return output, nil +} + +// 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 + } + + 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 +} + +// 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 + } + + 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_runner_raise_test.go b/impl/task_runner_raise_test.go new file mode 100644 index 0000000..3527283 --- /dev/null +++ b/impl/task_runner_raise_test.go @@ -0,0 +1,165 @@ +// 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 ( + "encoding/json" + "errors" + "testing" + + "github.com/serverlessworkflow/sdk-go/v3/model" + "github.com/stretchr/testify/assert" +) + +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, nil) + 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") + + 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) { + ref := "someErrorRef" + raiseTask := &model.RaiseTask{ + Raise: model.RaiseTaskConfiguration{ + Error: model.RaiseTaskError{ + Ref: &ref, + }, + }, + } + + runner, err := NewRaiseTaskRunner("task_raise_ref", raiseTask, nil) + 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, nil) + 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") + + 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) { + 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) { + 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_runner_test.go b/impl/task_runner_test.go new file mode 100644 index 0000000..c5a76d7 --- /dev/null +++ b/impl/task_runner_test.go @@ -0,0 +1,330 @@ +// 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 ( + "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 +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") + + // 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) + return output, err +} + +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 +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{}{ + "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) + }) +} + +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()) + }) + }) +} + +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/task_set_test.go b/impl/task_set_test.go new file mode 100644 index 0000000..48ca18b --- /dev/null +++ b/impl/task_set_test.go @@ -0,0 +1,416 @@ +// 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 ( + "reflect" + "testing" + + "github.com/serverlessworkflow/sdk-go/v3/model" + "github.com/stretchr/testify/assert" +) + +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 := NewSetTaskRunner("task1", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_static", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_runtime_expr", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_nested_structures", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_static_dynamic", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_missing_input", setTask) + assert.NoError(t, err) + + output, err := executor.Run(input) + assert.NoError(t, err) + assert.Nil(t, output.(map[string]interface{})["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 := NewSetTaskRunner("task_expr_functions", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_conditional_expr", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_array_indexing", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_nested_condition", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_default_values", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_complex_nested", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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 := NewSetTaskRunner("task_multiple_expr", setTask) + assert.NoError(t, err) + + output, err := executor.Run(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/testdata/chained_set_tasks.yaml b/impl/testdata/chained_set_tasks.yaml new file mode 100644 index 0000000..8ee9a9c --- /dev/null +++ b/impl/testdata/chained_set_tasks.yaml @@ -0,0 +1,29 @@ +# 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' + 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..22cd1b2 --- /dev/null +++ b/impl/testdata/concatenating_strings.yaml @@ -0,0 +1,31 @@ +# 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' + 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..30135a5 --- /dev/null +++ b/impl/testdata/conditional_logic.yaml @@ -0,0 +1,26 @@ +# 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' + 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/conditional_logic_input_from.yaml b/impl/testdata/conditional_logic_input_from.yaml new file mode 100644 index 0000000..f64f3e8 --- /dev/null +++ b/impl/testdata/conditional_logic_input_from.yaml @@ -0,0 +1,25 @@ +# 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' + 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/for_colors.yaml b/impl/testdata/for_colors.yaml new file mode 100644 index 0000000..ac33620 --- /dev/null +++ b/impl/testdata/for_colors.yaml @@ -0,0 +1,28 @@ +# 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 + 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/impl/testdata/raise_conditional.yaml b/impl/testdata/raise_conditional.yaml new file mode 100644 index 0000000..2d9f809 --- /dev/null +++ b/impl/testdata/raise_conditional.yaml @@ -0,0 +1,32 @@ +# 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' + 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..96affe1 --- /dev/null +++ b/impl/testdata/raise_error_with_input.yaml @@ -0,0 +1,27 @@ +# 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 + 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..c464877 --- /dev/null +++ b/impl/testdata/raise_inline.yaml @@ -0,0 +1,27 @@ +# 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 + 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..33a203d --- /dev/null +++ b/impl/testdata/raise_reusable.yaml @@ -0,0 +1,30 @@ +# 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 + 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..1316818 --- /dev/null +++ b/impl/testdata/raise_undefined_reference.yaml @@ -0,0 +1,23 @@ +# 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 + name: raise-undefined-reference + version: '1.0.0' +do: + - missingError: + raise: + error: UndefinedError diff --git a/impl/testdata/sequential_set_colors.yaml b/impl/testdata/sequential_set_colors.yaml new file mode 100644 index 0000000..b956c71 --- /dev/null +++ b/impl/testdata/sequential_set_colors.yaml @@ -0,0 +1,31 @@ +# 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 + name: do + version: '1.0.0' +do: + - setRed: + set: + colors: ${ .colors + ["red"] } + - setGreen: + set: + colors: ${ .colors + ["green"] } + - setBlue: + set: + 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..53c4919 --- /dev/null +++ b/impl/testdata/sequential_set_colors_output_as.yaml @@ -0,0 +1,31 @@ +# 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 + 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/set_tasks_invalid_then.yaml b/impl/testdata/set_tasks_invalid_then.yaml new file mode 100644 index 0000000..325c0c2 --- /dev/null +++ b/impl/testdata/set_tasks_invalid_then.yaml @@ -0,0 +1,27 @@ +# 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' + 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..3c819bd --- /dev/null +++ b/impl/testdata/set_tasks_with_termination.yaml @@ -0,0 +1,27 @@ +# 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' + 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..e0f8155 --- /dev/null +++ b/impl/testdata/set_tasks_with_then.yaml @@ -0,0 +1,30 @@ +# 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' + 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/impl/testdata/task_export_schema.yaml b/impl/testdata/task_export_schema.yaml new file mode 100644 index 0000000..e63e869 --- /dev/null +++ b/impl/testdata/task_export_schema.yaml @@ -0,0 +1,32 @@ +# 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' + 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..d93b574 --- /dev/null +++ b/impl/testdata/task_input_schema.yaml @@ -0,0 +1,32 @@ +# 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' + 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..73d784b --- /dev/null +++ b/impl/testdata/task_output_schema.yaml @@ -0,0 +1,32 @@ +# 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' + 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..39a7df9 --- /dev/null +++ b/impl/testdata/task_output_schema_with_dynamic_value.yaml @@ -0,0 +1,32 @@ +# 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' + 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..fabf484 --- /dev/null +++ b/impl/testdata/workflow_input_schema.yaml @@ -0,0 +1,32 @@ +# 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' + 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/impl/utils.go b/impl/utils.go new file mode 100644 index 0000000..2cdf952 --- /dev/null +++ b/impl/utils.go @@ -0,0 +1,81 @@ +// 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 ( + "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{}) + 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 +} + +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 +} + +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/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/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..eeef71c --- /dev/null +++ b/model/errors.go @@ -0,0 +1,324 @@ +// 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 ( + "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 { + // 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"` +} + +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: NewStringOrRuntimeExpr(title), + Detail: NewStringOrRuntimeExpr(detail.Error()), + Instance: &JsonPointerOrRuntimeExpression{ + Value: instance, + }, + } + } + + return &Error{ + Type: NewUriTemplate(errType), + Status: status, + Title: NewStringOrRuntimeExpr(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: %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: %w", 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..12a00fb --- /dev/null +++ b/model/errors_test.go @@ -0,0 +1,139 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// 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/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/objects.go b/model/objects.go index ecfba00..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,26 @@ 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{}: + 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 @@ -102,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 @@ -150,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. @@ -211,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. @@ -258,3 +318,22 @@ 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) 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 c67a3ef..6a056cb 100644 --- a/model/runtime_expression.go +++ b/model/runtime_expression.go @@ -17,8 +17,8 @@ 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 +34,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. @@ -79,3 +66,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 3bbeb4d..4edbd40 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 @@ -92,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 @@ -146,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 { @@ -157,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() @@ -186,59 +188,49 @@ 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 } -// 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_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/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_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()) } 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_test.go b/model/task_test.go index 6fa5019..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" ) @@ -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/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/validator.go b/model/validator.go index 91c34b9..60b87b8 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" + + validator "github.com/go-playground/validator/v10" ) var ( 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 { diff --git a/model/workflow_test.go b/model/workflow_test.go index df90f1e..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" ) @@ -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, }, 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 4072331e14a482e1d4f68f723ebdafa0aa17862c Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:31:00 -0300 Subject: [PATCH 02/11] chores: upgrade golang.org/x/net to v0.37.0 (#228) Signed-off-by: Ricardo Zanini --- go.mod | 20 +++++++++++--------- go.sum | 27 ++++++++++++++------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index 15c63e3..32f8859 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,15 @@ module github.com/serverlessworkflow/sdk-go/v3 -go 1.22 +go 1.23.0 + +toolchain go1.24.0 require ( - github.com/go-playground/validator/v10 v10.24.0 + github.com/go-playground/validator/v10 v10.25.0 github.com/itchyny/gojq v0.12.17 github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 + github.com/xeipuuv/gojsonschema v1.2.0 sigs.k8s.io/yaml v1.4.0 ) @@ -15,18 +18,17 @@ require ( github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/leodido/go-urn v1.4.0 // indirect 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/gojsonpointer v0.0.0-20190905194746-02993c407bfb // 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 - golang.org/x/text v0.21.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3a19f04..80ed15c 100644 --- a/go.sum +++ b/go.sum @@ -9,11 +9,11 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= -github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= +github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= @@ -34,20 +34,21 @@ 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/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/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= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 5237b8645774f8d384edcac39d0d8a5566cb39f0 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Fri, 28 Mar 2025 13:07:01 -0300 Subject: [PATCH 03/11] Fix #229 - Refactor JQ Expr processing and For Implementation (#231) * Refactor expr packaged and shared context Signed-off-by: Ricardo Zanini * Fix #229 - Implement For task and refactor jq expr into context Signed-off-by: Ricardo Zanini * Add missing headers Signed-off-by: Ricardo Zanini * Add nolint:unused Signed-off-by: Ricardo Zanini --------- Signed-off-by: Ricardo Zanini --- README.md | 2 +- expr/expr.go | 112 ----- go.mod | 2 + go.sum | 4 + impl/context.go | 151 ------- impl/ctx/context.go | 407 ++++++++++++++++++ impl/{ => ctx}/status_phase.go | 2 +- impl/expr/expr.go | 133 ++++++ impl/expr/expr_test.go | 263 +++++++++++ impl/json_pointer.go | 77 ++++ .../json_pointer_test.go | 84 ++-- impl/runner.go | 134 ++++-- impl/{task_runner_test.go => runner_test.go} | 107 ++++- impl/task_runner.go | 247 ++--------- impl/task_runner_do.go | 78 ++-- impl/task_runner_for.go | 135 ++++++ impl/task_runner_raise.go | 105 +++++ impl/task_runner_raise_test.go | 15 +- impl/task_runner_set.go | 56 +++ impl/task_set_test.go | 26 +- impl/testdata/for_nested_loops.yaml | 35 ++ impl/testdata/for_sum_numbers.yaml | 30 ++ impl/testdata/raise_inline.yaml | 2 +- impl/utils.go | 31 +- model/errors.go | 63 +-- model/objects.go | 6 + model/runtime_expression.go | 31 +- model/runtime_expression_test.go | 149 +++++++ model/workflow.go | 14 + 29 files changed, 1811 insertions(+), 690 deletions(-) delete mode 100644 expr/expr.go delete mode 100644 impl/context.go create mode 100644 impl/ctx/context.go rename impl/{ => ctx}/status_phase.go (99%) create mode 100644 impl/expr/expr.go create mode 100644 impl/expr/expr_test.go create mode 100644 impl/json_pointer.go rename model/errors_test.go => impl/json_pointer_test.go (53%) rename impl/{task_runner_test.go => runner_test.go} (81%) create mode 100644 impl/task_runner_for.go create mode 100644 impl/task_runner_raise.go create mode 100644 impl/task_runner_set.go create mode 100644 impl/testdata/for_nested_loops.yaml create mode 100644 impl/testdata/for_sum_numbers.yaml diff --git a/README.md b/README.md index 9daabf0..f05e54c 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ The table below lists the current state of this implementation. This table is a | Task Call | ❌ | | Task Do | βœ… | | Task Emit | ❌ | -| Task For | ❌ | +| Task For | βœ… | | Task Fork | ❌ | | Task Listen | ❌ | | Task Raise | βœ… | diff --git a/expr/expr.go b/expr/expr.go deleted file mode 100644 index cd5a755..0000000 --- a/expr/expr.go +++ /dev/null @@ -1,112 +0,0 @@ -// 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 ( - "errors" - "fmt" - "strings" - - "github.com/itchyny/gojq" -) - -// 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 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 - } -} - -// 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 - 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/go.mod b/go.mod index 32f8859..e7947a8 100644 --- a/go.mod +++ b/go.mod @@ -19,9 +19,11 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/relvacode/iso8601 v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect diff --git a/go.sum b/go.sum index 80ed15c..e6e3d38 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= @@ -23,6 +25,8 @@ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjS 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/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= +github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= 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= diff --git a/impl/context.go b/impl/context.go deleted file mode 100644 index ae9375e..0000000 --- a/impl/context.go +++ /dev/null @@ -1,151 +0,0 @@ -// 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" - "errors" - "sync" -) - -type ctxKey string - -const runnerCtxKey ctxKey = "wfRunnerContext" - -// 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 - context map[string]interface{} - StatusPhase []StatusPhaseLog - TasksStatusPhase map[string][]StatusPhaseLog // Holds `$context` as the key -} - -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{} - } - ctx.StatusPhase = append(ctx.StatusPhase, NewStatusPhaseLog(status)) -} - -func (ctx *WorkflowContext) SetTaskStatus(task string, status StatusPhase) { - ctx.mu.Lock() - defer ctx.mu.Unlock() - if ctx.TasksStatusPhase == nil { - ctx.TasksStatusPhase = map[string][]StatusPhaseLog{} - } - ctx.TasksStatusPhase[task] = append(ctx.TasksStatusPhase[task], NewStatusPhaseLog(status)) -} - -// 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{}) - } - ctx.context["$context"] = value -} - -// 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 ctx.context["$context"] -} - -// SetInput safely sets the input -func (ctx *WorkflowContext) SetInput(input interface{}) { - ctx.mu.Lock() - defer ctx.mu.Unlock() - ctx.input = input -} - -// GetInput safely retrieves the input -func (ctx *WorkflowContext) GetInput() interface{} { - ctx.mu.Lock() - defer ctx.mu.Unlock() - return ctx.input -} - -// SetOutput safely sets the output -func (ctx *WorkflowContext) SetOutput(output interface{}) { - ctx.mu.Lock() - defer ctx.mu.Unlock() - ctx.output = output -} - -// GetOutput safely retrieves the 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 (ctx *WorkflowContext) GetInputAsMap() map[string]interface{} { - ctx.mu.Lock() - defer ctx.mu.Unlock() - - 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{}{ - "": 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 (ctx *WorkflowContext) GetOutputAsMap() map[string]interface{} { - ctx.mu.Lock() - defer ctx.mu.Unlock() - - 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{}{ - "": ctx.output, - } -} - -// WithWorkflowContext adds the WorkflowContext to a parent context -func WithWorkflowContext(parent context.Context, wfCtx *WorkflowContext) context.Context { - return context.WithValue(parent, runnerCtxKey, wfCtx) -} - -// 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") - } - return wfCtx, nil -} diff --git a/impl/ctx/context.go b/impl/ctx/context.go new file mode 100644 index 0000000..1f0d716 --- /dev/null +++ b/impl/ctx/context.go @@ -0,0 +1,407 @@ +// 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 ctx + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/google/uuid" + "github.com/serverlessworkflow/sdk-go/v3/model" + "sync" + "time" +) + +var ErrWorkflowContextNotFound = errors.New("workflow context not found") + +var _ WorkflowContext = &workflowContext{} + +type ctxKey string + +const ( + runnerCtxKey ctxKey = "wfRunnerContext" + + varsContext = "$context" + varsInput = "$input" + varsOutput = "$output" + varsWorkflow = "$workflow" + varsRuntime = "$runtime" + varsTask = "$task" + + // TODO: script during the release to update this value programmatically + runtimeVersion = "v3.1.0" + runtimeName = "CNCF Serverless Workflow Specification Go SDK" +) + +type WorkflowContext interface { + SetStartedAt(t time.Time) + SetStatus(status StatusPhase) + SetRawInput(input interface{}) + SetInstanceCtx(value interface{}) + GetInstanceCtx() interface{} + SetInput(input interface{}) + GetInput() interface{} + SetOutput(output interface{}) + GetOutput() interface{} + GetOutputAsMap() map[string]interface{} + GetVars() map[string]interface{} + SetTaskStatus(task string, status StatusPhase) + SetTaskRawInput(input interface{}) + SetTaskRawOutput(output interface{}) + SetTaskDef(task model.Task) error + SetTaskStartedAt(startedAt time.Time) + SetTaskName(name string) + SetTaskReference(ref string) + GetTaskReference() string + ClearTaskContext() + SetLocalExprVars(vars map[string]interface{}) + AddLocalExprVars(vars map[string]interface{}) + RemoveLocalExprVars(keys ...string) +} + +// 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 + context map[string]interface{} // Holds `$context` as the key + workflowDescriptor map[string]interface{} // $workflow representation in the context + taskDescriptor map[string]interface{} // $task representation in the context + localExprVars map[string]interface{} // Local expression variables defined in a given task or private context. E.g. a For task $item. + StatusPhase []StatusPhaseLog + TasksStatusPhase map[string][]StatusPhaseLog +} + +func NewWorkflowContext(workflow *model.Workflow) (WorkflowContext, error) { + workflowCtx := &workflowContext{} + workflowDef, err := workflow.AsMap() + if err != nil { + return nil, err + } + workflowCtx.taskDescriptor = map[string]interface{}{} + workflowCtx.workflowDescriptor = map[string]interface{}{ + varsWorkflow: map[string]interface{}{ + "id": uuid.NewString(), + "definition": workflowDef, + }, + } + workflowCtx.SetStatus(PendingStatus) + + return workflowCtx, nil +} + +// WithWorkflowContext adds the workflowContext to a parent context +func WithWorkflowContext(parent context.Context, wfCtx WorkflowContext) context.Context { + return context.WithValue(parent, runnerCtxKey, wfCtx) +} + +// GetWorkflowContext retrieves the workflowContext from a context +func GetWorkflowContext(ctx context.Context) (WorkflowContext, error) { + wfCtx, ok := ctx.Value(runnerCtxKey).(*workflowContext) + if !ok { + return nil, ErrWorkflowContextNotFound + } + return wfCtx, nil +} + +func (ctx *workflowContext) SetStartedAt(t time.Time) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + wf, ok := ctx.workflowDescriptor[varsWorkflow].(map[string]interface{}) + if !ok { + wf = make(map[string]interface{}) + ctx.workflowDescriptor[varsWorkflow] = wf + } + + startedAt, ok := wf["startedAt"].(map[string]interface{}) + if !ok { + startedAt = make(map[string]interface{}) + wf["startedAt"] = startedAt + } + + startedAt["iso8601"] = t.UTC().Format(time.RFC3339) +} + +func (ctx *workflowContext) SetRawInput(input interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + // Ensure the outer "workflow" map + wf, ok := ctx.workflowDescriptor[varsWorkflow].(map[string]interface{}) + if !ok { + wf = make(map[string]interface{}) + ctx.workflowDescriptor[varsWorkflow] = wf + } + + // Store the input + wf["input"] = input +} + +func (ctx *workflowContext) AddLocalExprVars(vars map[string]interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + if ctx.localExprVars == nil { + ctx.localExprVars = map[string]interface{}{} + } + for k, v := range vars { + ctx.localExprVars[k] = v + } +} + +func (ctx *workflowContext) RemoveLocalExprVars(keys ...string) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + if ctx.localExprVars == nil { + return + } + + for _, k := range keys { + delete(ctx.localExprVars, k) + } +} + +func (ctx *workflowContext) SetLocalExprVars(vars map[string]interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + ctx.localExprVars = vars +} + +func (ctx *workflowContext) GetVars() map[string]interface{} { + vars := make(map[string]interface{}) + vars[varsInput] = ctx.GetInput() + vars[varsOutput] = ctx.GetOutput() + vars[varsContext] = ctx.GetInstanceCtx() + vars[varsTask] = ctx.taskDescriptor[varsTask] + vars[varsWorkflow] = ctx.workflowDescriptor[varsWorkflow] + vars[varsRuntime] = map[string]interface{}{ + "name": runtimeName, + "version": runtimeVersion, + } + for varName, varValue := range ctx.localExprVars { + vars[varName] = varValue + } + return vars +} + +func (ctx *workflowContext) SetStatus(status StatusPhase) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + if ctx.StatusPhase == nil { + ctx.StatusPhase = []StatusPhaseLog{} + } + ctx.StatusPhase = append(ctx.StatusPhase, NewStatusPhaseLog(status)) +} + +// 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{}) + } + ctx.context[varsContext] = value +} + +// 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 ctx.context[varsContext] +} + +// SetInput safely sets the input +func (ctx *workflowContext) SetInput(input interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + ctx.input = input +} + +// GetInput safely retrieves the input +func (ctx *workflowContext) GetInput() interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() + return ctx.input +} + +// SetOutput safely sets the output +func (ctx *workflowContext) SetOutput(output interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + ctx.output = output +} + +// GetOutput safely retrieves the 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 (ctx *workflowContext) GetInputAsMap() map[string]interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + 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{}{ + "": 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 (ctx *workflowContext) GetOutputAsMap() map[string]interface{} { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + 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{}{ + "": ctx.output, + } +} + +func (ctx *workflowContext) SetTaskStatus(task string, status StatusPhase) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + if ctx.TasksStatusPhase == nil { + ctx.TasksStatusPhase = map[string][]StatusPhaseLog{} + } + ctx.TasksStatusPhase[task] = append(ctx.TasksStatusPhase[task], NewStatusPhaseLog(status)) +} + +func (ctx *workflowContext) SetTaskRawInput(input interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + task, ok := ctx.taskDescriptor[varsTask].(map[string]interface{}) + if !ok { + task = make(map[string]interface{}) + ctx.taskDescriptor[varsTask] = task + } + + task["input"] = input +} + +func (ctx *workflowContext) SetTaskRawOutput(output interface{}) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + task, ok := ctx.taskDescriptor[varsTask].(map[string]interface{}) + if !ok { + task = make(map[string]interface{}) + ctx.taskDescriptor[varsTask] = task + } + + task["output"] = output +} + +func (ctx *workflowContext) SetTaskDef(task model.Task) error { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + if task == nil { + return errors.New("SetTaskDef called with nil model.Task") + } + + defBytes, err := json.Marshal(task) + if err != nil { + return fmt.Errorf("failed to marshal task: %w", err) + } + + var defMap map[string]interface{} + if err := json.Unmarshal(defBytes, &defMap); err != nil { + return fmt.Errorf("failed to unmarshal task into map: %w", err) + } + + taskMap, ok := ctx.taskDescriptor[varsTask].(map[string]interface{}) + if !ok { + taskMap = make(map[string]interface{}) + ctx.taskDescriptor[varsTask] = taskMap + } + + taskMap["definition"] = defMap + + return nil +} + +func (ctx *workflowContext) SetTaskStartedAt(startedAt time.Time) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + task, ok := ctx.taskDescriptor[varsTask].(map[string]interface{}) + if !ok { + task = make(map[string]interface{}) + ctx.taskDescriptor[varsTask] = task + } + + task["startedAt"] = startedAt.UTC().Format(time.RFC3339) +} + +func (ctx *workflowContext) SetTaskName(name string) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + task, ok := ctx.taskDescriptor[varsTask].(map[string]interface{}) + if !ok { + task = make(map[string]interface{}) + ctx.taskDescriptor[varsTask] = task + } + + task["name"] = name +} + +func (ctx *workflowContext) SetTaskReference(ref string) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + task, ok := ctx.taskDescriptor[varsTask].(map[string]interface{}) + if !ok { + task = make(map[string]interface{}) + ctx.taskDescriptor[varsTask] = task + } + + task["reference"] = ref +} + +func (ctx *workflowContext) GetTaskReference() string { + ctx.mu.Lock() + defer ctx.mu.Unlock() + task, ok := ctx.taskDescriptor[varsTask].(map[string]interface{}) + if !ok { + return "" + } + return task["reference"].(string) +} + +func (ctx *workflowContext) ClearTaskContext() { + ctx.mu.Lock() + defer ctx.mu.Unlock() + ctx.taskDescriptor[varsTask] = make(map[string]interface{}) +} diff --git a/impl/status_phase.go b/impl/ctx/status_phase.go similarity index 99% rename from impl/status_phase.go rename to impl/ctx/status_phase.go index ca61fad..ddcab9c 100644 --- a/impl/status_phase.go +++ b/impl/ctx/status_phase.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package impl +package ctx import "time" diff --git a/impl/expr/expr.go b/impl/expr/expr.go new file mode 100644 index 0000000..03d558e --- /dev/null +++ b/impl/expr/expr.go @@ -0,0 +1,133 @@ +// 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 ( + "context" + "errors" + "fmt" + "github.com/itchyny/gojq" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +func TraverseAndEvaluateWithVars(node interface{}, input interface{}, variables map[string]interface{}, nodeContext context.Context) (interface{}, error) { + if err := mergeContextInVars(nodeContext, variables); err != nil { + return nil, err + } + return traverseAndEvaluate(node, input, variables) +} + +// TraverseAndEvaluate recursively processes and evaluates all expressions in a JSON-like structure +func TraverseAndEvaluate(node interface{}, input interface{}, nodeContext context.Context) (interface{}, error) { + return TraverseAndEvaluateWithVars(node, input, map[string]interface{}{}, nodeContext) +} + +func traverseAndEvaluate(node interface{}, input interface{}, variables 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, variables) + 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, variables) + 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 model.IsStrictExpr(v) { + return evaluateJQExpression(model.SanitizeExpr(v), input, variables) + } + 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 interface{}, variables map[string]interface{}) (interface{}, error) { + query, err := gojq.Parse(expression) + if err != nil { + return nil, fmt.Errorf("failed to parse jq expression: %s, error: %w", expression, err) + } + + // Get the variable names & values in a single pass: + names, values := getVariableNamesAndValues(variables) + + code, err := gojq.Compile(query, gojq.WithVariables(names)) + if err != nil { + return nil, fmt.Errorf("failed to compile jq expression: %s, error: %w", expression, err) + } + + iter := code.Run(input, values...) + result, ok := iter.Next() + if !ok { + return nil, errors.New("no result from jq evaluation") + } + + // If there's an error from the jq engine, report it + if errVal, isErr := result.(error); isErr { + return nil, fmt.Errorf("jq evaluation error: %w", errVal) + } + + return result, nil +} + +// getVariableNamesAndValues constructs two slices, where 'names[i]' matches 'values[i]'. +func getVariableNamesAndValues(vars map[string]interface{}) ([]string, []interface{}) { + names := make([]string, 0, len(vars)) + values := make([]interface{}, 0, len(vars)) + + for k, v := range vars { + names = append(names, k) + values = append(values, v) + } + return names, values +} + +func mergeContextInVars(nodeCtx context.Context, variables map[string]interface{}) error { + if variables == nil { + variables = make(map[string]interface{}) + } + wfCtx, err := ctx.GetWorkflowContext(nodeCtx) + if err != nil { + if errors.Is(err, ctx.ErrWorkflowContextNotFound) { + return nil + } + return err + } + // merge + for k, val := range wfCtx.GetVars() { + variables[k] = val + } + + return nil +} diff --git a/impl/expr/expr_test.go b/impl/expr/expr_test.go new file mode 100644 index 0000000..f2af54a --- /dev/null +++ b/impl/expr/expr_test.go @@ -0,0 +1,263 @@ +// 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 ( + "context" + "fmt" + "testing" + + "github.com/itchyny/gojq" +) + +func TestTraverseAndEvaluate(t *testing.T) { + t.Run("Simple no-expression map", func(t *testing.T) { + node := map[string]interface{}{ + "key": "value", + "num": 123, + } + result, err := TraverseAndEvaluate(node, nil, context.TODO()) + if err != nil { + t.Fatalf("TraverseAndEvaluate() unexpected error: %v", err) + } + + got, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("TraverseAndEvaluate() did not return a map") + } + if got["key"] != "value" || got["num"] != 123 { + t.Errorf("TraverseAndEvaluate() returned unexpected map data: %#v", got) + } + }) + + t.Run("Expression in map", func(t *testing.T) { + node := map[string]interface{}{ + "expr": "${ .foo }", + } + input := map[string]interface{}{ + "foo": "bar", + } + + result, err := TraverseAndEvaluate(node, input, context.TODO()) + if err != nil { + t.Fatalf("TraverseAndEvaluate() unexpected error: %v", err) + } + + got, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("TraverseAndEvaluate() did not return a map") + } + if got["expr"] != "bar" { + t.Errorf("TraverseAndEvaluate() = %v, want %v", got["expr"], "bar") + } + }) + + t.Run("Expression in array", func(t *testing.T) { + node := []interface{}{ + "static", + "${ .foo }", + } + input := map[string]interface{}{ + "foo": "bar", + } + + result, err := TraverseAndEvaluate(node, input, context.TODO()) + if err != nil { + t.Fatalf("TraverseAndEvaluate() unexpected error: %v", err) + } + + got, ok := result.([]interface{}) + if !ok { + t.Fatalf("TraverseAndEvaluate() did not return an array") + } + if got[0] != "static" { + t.Errorf("TraverseAndEvaluate()[0] = %v, want 'static'", got[0]) + } + if got[1] != "bar" { + t.Errorf("TraverseAndEvaluate()[1] = %v, want 'bar'", got[1]) + } + }) + + t.Run("Nested structures", func(t *testing.T) { + node := map[string]interface{}{ + "level1": []interface{}{ + map[string]interface{}{ + "expr": "${ .foo }", + }, + }, + } + input := map[string]interface{}{ + "foo": "nestedValue", + } + + result, err := TraverseAndEvaluate(node, input, context.TODO()) + if err != nil { + t.Fatalf("TraverseAndEvaluate() error: %v", err) + } + + resMap, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("TraverseAndEvaluate() did not return a map at top-level") + } + + level1, ok := resMap["level1"].([]interface{}) + if !ok { + t.Fatalf("TraverseAndEvaluate() did not return an array for resMap['level1']") + } + + level1Map, ok := level1[0].(map[string]interface{}) + if !ok { + t.Fatalf("TraverseAndEvaluate() did not return a map for level1[0]") + } + + if level1Map["expr"] != "nestedValue" { + t.Errorf("TraverseAndEvaluate() = %v, want %v", level1Map["expr"], "nestedValue") + } + }) + + t.Run("Invalid JQ expression", func(t *testing.T) { + node := "${ .foo( }" + input := map[string]interface{}{ + "foo": "bar", + } + + _, err := TraverseAndEvaluate(node, input, context.TODO()) + if err == nil { + t.Errorf("TraverseAndEvaluate() expected error for invalid JQ, got nil") + } + }) +} + +func TestTraverseAndEvaluateWithVars(t *testing.T) { + t.Run("Variable usage in expression", func(t *testing.T) { + node := map[string]interface{}{ + "expr": "${ $myVar }", + } + variables := map[string]interface{}{ + "$myVar": "HelloVars", + } + input := map[string]interface{}{} + + result, err := TraverseAndEvaluateWithVars(node, input, variables, context.TODO()) + if err != nil { + t.Fatalf("TraverseAndEvaluateWithVars() unexpected error: %v", err) + } + got, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("TraverseAndEvaluateWithVars() did not return a map") + } + if got["expr"] != "HelloVars" { + t.Errorf("TraverseAndEvaluateWithVars() = %v, want %v", got["expr"], "HelloVars") + } + }) + + t.Run("Reference variable that isn't defined", func(t *testing.T) { + // This tries to use a variable that isn't passed in, + // so presumably it yields an error about an undefined variable. + node := "${ $notProvided }" + input := map[string]interface{}{ + "foo": "bar", + } + variables := map[string]interface{}{} // intentionally empty + + _, err := TraverseAndEvaluateWithVars(node, input, variables, context.TODO()) + if err == nil { + t.Errorf("TraverseAndEvaluateWithVars() expected error for undefined variable, got nil") + } else { + t.Logf("Got expected error: %v", err) + } + }) +} + +func TestEvaluateJQExpressionDirect(t *testing.T) { + // This tests the core evaluator directly for errors and success. + t.Run("Successful eval", func(t *testing.T) { + expression := ".foo" + input := map[string]interface{}{"foo": "bar"} + variables := map[string]interface{}{} + result, err := callEvaluateJQ(expression, input, variables) + if err != nil { + t.Fatalf("evaluateJQExpression() error = %v, want nil", err) + } + if result != "bar" { + t.Errorf("evaluateJQExpression() = %v, want 'bar'", result) + } + }) + + t.Run("Parse error", func(t *testing.T) { + expression := ".foo(" + input := map[string]interface{}{"foo": "bar"} + variables := map[string]interface{}{} + _, err := callEvaluateJQ(expression, input, variables) + if err == nil { + t.Errorf("evaluateJQExpression() expected parse error, got nil") + } + }) + + t.Run("Runtime error in evaluation (undefined variable)", func(t *testing.T) { + expression := "$undefinedVar" + input := map[string]interface{}{ + "foo": []interface{}{1, 2}, + } + variables := map[string]interface{}{} + _, err := callEvaluateJQ(expression, input, variables) + if err == nil { + t.Errorf("callEvaluateJQ() expected runtime error, got nil") + } else { + t.Logf("Got expected error: %v", err) + } + }) +} + +// Helper to call the unexported evaluateJQExpression via a wrapper in tests. +// Alternatively, you could move `evaluateJQExpression` into a separate file that +// is also in package `expr`, then test it directly if needed. +func callEvaluateJQ(expression string, input interface{}, variables map[string]interface{}) (interface{}, error) { + // Replicate the logic from evaluateJQExpression for direct testing + query, err := gojq.Parse(expression) + if err != nil { + return nil, fmt.Errorf("failed to parse: %w", err) + } + code, err := gojq.Compile(query, gojq.WithVariables(exprGetVariableNames(variables))) + if err != nil { + return nil, fmt.Errorf("failed to compile: %w", err) + } + iter := code.Run(input, exprGetVariableValues(variables)...) + result, ok := iter.Next() + if !ok { + return nil, fmt.Errorf("no result from jq evaluation") + } + if e, isErr := result.(error); isErr { + return nil, fmt.Errorf("runtime error: %w", e) + } + return result, nil +} + +// Local copies of the variable-gathering logic from your code: +func exprGetVariableNames(variables map[string]interface{}) []string { + names := make([]string, 0, len(variables)) + for name := range variables { + names = append(names, name) + } + return names +} + +func exprGetVariableValues(variables map[string]interface{}) []interface{} { + vals := make([]interface{}, 0, len(variables)) + for _, val := range variables { + vals = append(vals, val) + } + return vals +} diff --git a/impl/json_pointer.go b/impl/json_pointer.go new file mode 100644 index 0000000..4d276ff --- /dev/null +++ b/impl/json_pointer.go @@ -0,0 +1,77 @@ +// 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 ( + "encoding/json" + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/model" + "reflect" + "strings" +) + +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 *model.Workflow, targetNode interface{}) (string, error) { + // Convert struct to JSON + jsonData, err := json.Marshal(workflow) + if err != nil { + 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: %w", 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/impl/json_pointer_test.go similarity index 53% rename from model/errors_test.go rename to impl/json_pointer_test.go index 12a00fb..76077bc 100644 --- a/model/errors_test.go +++ b/impl/json_pointer_test.go @@ -12,21 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -package model +package impl import ( - "testing" - + "github.com/serverlessworkflow/sdk-go/v3/model" "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 }"}}}, + workflow := &model.Workflow{ + Document: model.Document{Name: "simple-workflow"}, + Do: &model.TaskList{ + &model.TaskItem{Key: "task1", Task: &model.SetTask{Set: map[string]interface{}{"value": 10}}}, + &model.TaskItem{Key: "task2", Task: &model.SetTask{Set: map[string]interface{}{"double": "${ .value * 2 }"}}}, }, } @@ -37,11 +37,11 @@ func TestGenerateJSONPointer_SimpleTask(t *testing.T) { // 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 }"}}}, + workflow := &model.Workflow{ + Document: model.Document{Name: "simple-workflow"}, + Do: &model.TaskList{ + &model.TaskItem{Key: "task1", Task: &model.SetTask{Set: map[string]interface{}{"value": 10}}}, + &model.TaskItem{Key: "task2", Task: &model.SetTask{Set: map[string]interface{}{"double": "${ .value * 2 }"}}}, }, } @@ -51,17 +51,17 @@ func TestGenerateJSONPointer_Document(t *testing.T) { } func TestGenerateJSONPointer_ForkTask(t *testing.T) { - workflow := &Workflow{ - Document: Document{Name: "fork-example"}, - Do: &TaskList{ - &TaskItem{ + workflow := &model.Workflow{ + Document: model.Document{Name: "fork-example"}, + Do: &model.TaskList{ + &model.TaskItem{ Key: "raiseAlarm", - Task: &ForkTask{ - Fork: ForkTaskConfiguration{ + Task: &model.ForkTask{ + Fork: model.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")}}}, + Branches: &model.TaskList{ + {Key: "callNurse", Task: &model.CallHTTP{Call: "http", With: model.HTTPArguments{Method: "put", Endpoint: model.NewEndpoint("https://hospital.com/api/alert/nurses")}}}, + {Key: "callDoctor", Task: &model.CallHTTP{Call: "http", With: model.HTTPArguments{Method: "put", Endpoint: model.NewEndpoint("https://hospital.com/api/alert/doctor")}}}, }, }, }, @@ -76,23 +76,23 @@ func TestGenerateJSONPointer_ForkTask(t *testing.T) { // TestGenerateJSONPointer_DeepNestedTask tests multiple nested task levels. func TestGenerateJSONPointer_DeepNestedTask(t *testing.T) { - workflow := &Workflow{ - Document: Document{Name: "deep-nested"}, - Do: &TaskList{ - &TaskItem{ + workflow := &model.Workflow{ + Document: model.Document{Name: "deep-nested"}, + Do: &model.TaskList{ + &model.TaskItem{ Key: "step1", - Task: &ForkTask{ - Fork: ForkTaskConfiguration{ + Task: &model.ForkTask{ + Fork: model.ForkTaskConfiguration{ Compete: false, - Branches: &TaskList{ + Branches: &model.TaskList{ { Key: "branchA", - Task: &ForkTask{ - Fork: ForkTaskConfiguration{ - Branches: &TaskList{ + Task: &model.ForkTask{ + Fork: model.ForkTaskConfiguration{ + Branches: &model.TaskList{ { Key: "deepTask", - Task: &SetTask{Set: map[string]interface{}{"result": "done"}}, + Task: &model.SetTask{Set: map[string]interface{}{"result": "done"}}, }, }, }, @@ -112,10 +112,10 @@ func TestGenerateJSONPointer_DeepNestedTask(t *testing.T) { // 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}}}, + workflow := &model.Workflow{ + Document: model.Document{Name: "nonexistent-test"}, + Do: &model.TaskList{ + &model.TaskItem{Key: "taskA", Task: &model.SetTask{Set: map[string]interface{}{"value": 5}}}, }, } @@ -125,11 +125,11 @@ func TestGenerateJSONPointer_NonExistentTask(t *testing.T) { // 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")}}}, + workflow := &model.Workflow{ + Document: model.Document{Name: "mixed-tasks"}, + Do: &model.TaskList{ + &model.TaskItem{Key: "compute", Task: &model.SetTask{Set: map[string]interface{}{"result": 42}}}, + &model.TaskItem{Key: "notify", Task: &model.CallHTTP{Call: "http", With: model.HTTPArguments{Method: "post", Endpoint: model.NewEndpoint("https://api.notify.com")}}}, }, } diff --git a/impl/runner.go b/impl/runner.go index c219886..1c9ad8b 100644 --- a/impl/runner.go +++ b/impl/runner.go @@ -17,57 +17,117 @@ package impl import ( "context" "fmt" - + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" + "time" ) var _ WorkflowRunner = &workflowRunnerImpl{} +var _ TaskSupport = &workflowRunnerImpl{} +// WorkflowRunner is the public API to run Workflows type WorkflowRunner interface { GetWorkflowDef() *model.Workflow Run(input interface{}) (output interface{}, err error) - GetContext() *WorkflowContext + GetWorkflowCtx() ctx.WorkflowContext } -func NewDefaultRunner(workflow *model.Workflow) WorkflowRunner { - wfContext := &WorkflowContext{} - wfContext.SetStatus(PendingStatus) +func NewDefaultRunner(workflow *model.Workflow) (WorkflowRunner, error) { + wfContext, err := ctx.NewWorkflowContext(workflow) + if err != nil { + return nil, err + } // TODO: based on the workflow definition, the context might change. - ctx := WithWorkflowContext(context.Background(), wfContext) + objCtx := ctx.WithWorkflowContext(context.Background(), wfContext) return &workflowRunnerImpl{ Workflow: workflow, - Context: ctx, + Context: objCtx, RunnerCtx: wfContext, - } + }, nil } type workflowRunnerImpl struct { Workflow *model.Workflow Context context.Context - RunnerCtx *WorkflowContext + RunnerCtx ctx.WorkflowContext } -func (wr *workflowRunnerImpl) GetContext() *WorkflowContext { - return wr.RunnerCtx +func (wr *workflowRunnerImpl) RemoveLocalExprVars(keys ...string) { + wr.RunnerCtx.RemoveLocalExprVars(keys...) +} + +func (wr *workflowRunnerImpl) AddLocalExprVars(vars map[string]interface{}) { + wr.RunnerCtx.AddLocalExprVars(vars) +} + +func (wr *workflowRunnerImpl) SetLocalExprVars(vars map[string]interface{}) { + wr.RunnerCtx.SetLocalExprVars(vars) +} + +func (wr *workflowRunnerImpl) SetTaskReferenceFromName(taskName string) error { + ref, err := GenerateJSONPointer(wr.Workflow, taskName) + if err != nil { + return err + } + wr.RunnerCtx.SetTaskReference(ref) + return nil +} + +func (wr *workflowRunnerImpl) GetTaskReference() string { + return wr.RunnerCtx.GetTaskReference() +} + +func (wr *workflowRunnerImpl) SetTaskRawInput(input interface{}) { + wr.RunnerCtx.SetTaskRawInput(input) +} + +func (wr *workflowRunnerImpl) SetTaskRawOutput(output interface{}) { + wr.RunnerCtx.SetTaskRawOutput(output) } -func (wr *workflowRunnerImpl) GetTaskContext() TaskContext { +func (wr *workflowRunnerImpl) SetTaskDef(task model.Task) error { + return wr.RunnerCtx.SetTaskDef(task) +} + +func (wr *workflowRunnerImpl) SetTaskStartedAt(startedAt time.Time) { + wr.RunnerCtx.SetTaskStartedAt(startedAt) +} + +func (wr *workflowRunnerImpl) SetTaskName(name string) { + wr.RunnerCtx.SetTaskName(name) +} + +func (wr *workflowRunnerImpl) GetContext() context.Context { + return wr.Context +} + +func (wr *workflowRunnerImpl) GetWorkflowCtx() ctx.WorkflowContext { return wr.RunnerCtx } +func (wr *workflowRunnerImpl) SetTaskStatus(task string, status ctx.StatusPhase) { + wr.RunnerCtx.SetTaskStatus(task, status) +} + func (wr *workflowRunnerImpl) GetWorkflowDef() *model.Workflow { return wr.Workflow } +func (wr *workflowRunnerImpl) SetWorkflowInstanceCtx(value interface{}) { + wr.RunnerCtx.SetInstanceCtx(value) +} + // 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, "/") + wr.RunnerCtx.SetStatus(ctx.FaultedStatus) + err = wr.wrapWorkflowError(err) } }() + wr.RunnerCtx.SetRawInput(input) + // Process input if input, err = wr.processInput(input); err != nil { return nil, err @@ -75,42 +135,57 @@ func (wr *workflowRunnerImpl) Run(input interface{}) (output interface{}, err er wr.RunnerCtx.SetInput(input) // Run tasks sequentially - wr.RunnerCtx.SetStatus(RunningStatus) + wr.RunnerCtx.SetStatus(ctx.RunningStatus) doRunner, err := NewDoTaskRunner(wr.Workflow.Do, wr) if err != nil { return nil, err } + wr.RunnerCtx.SetStartedAt(time.Now()) output, err = doRunner.Run(wr.RunnerCtx.GetInput()) if err != nil { return nil, err } + wr.RunnerCtx.ClearTaskContext() + // Process output if output, err = wr.processOutput(output); err != nil { return nil, err } wr.RunnerCtx.SetOutput(output) - wr.RunnerCtx.SetStatus(CompletedStatus) + wr.RunnerCtx.SetStatus(ctx.CompletedStatus) return output, nil } // wrapWorkflowError ensures workflow errors have a proper instance reference. -func (wr *workflowRunnerImpl) wrapWorkflowError(err error, taskName string) error { +func (wr *workflowRunnerImpl) wrapWorkflowError(err error) error { + taskReference := wr.RunnerCtx.GetTaskReference() + if len(taskReference) == 0 { + taskReference = "/" + } if knownErr := model.AsError(err); knownErr != nil { - return knownErr.WithInstanceRef(wr.Workflow, taskName) + return knownErr.WithInstanceRef(wr.Workflow, taskReference) } - return model.NewErrRuntime(fmt.Errorf("workflow '%s', task '%s': %w", wr.Workflow.Document.Name, taskName, err), taskName) + return model.NewErrRuntime(fmt.Errorf("workflow '%s', task '%s': %w", wr.Workflow.Document.Name, taskReference, err), taskReference) } // processInput validates and transforms input if needed. func (wr *workflowRunnerImpl) processInput(input interface{}) (output interface{}, err error) { if wr.Workflow.Input != nil { - output, err = processIO(input, wr.Workflow.Input.Schema, wr.Workflow.Input.From, "/") - if err != nil { - return nil, err + if wr.Workflow.Input.Schema != nil { + if err = validateSchema(input, wr.Workflow.Input.Schema, "/"); err != nil { + return nil, err + } + } + + if wr.Workflow.Input.From != nil { + output, err = traverseAndEvaluate(wr.Workflow.Input.From, input, "/", wr.Context) + if err != nil { + return nil, err + } + return output, nil } - return output, nil } return input, nil } @@ -118,7 +193,18 @@ func (wr *workflowRunnerImpl) processInput(input interface{}) (output interface{ // processOutput applies output transformations. func (wr *workflowRunnerImpl) processOutput(output interface{}) (interface{}, error) { if wr.Workflow.Output != nil { - return processIO(output, wr.Workflow.Output.Schema, wr.Workflow.Output.As, "/") + if wr.Workflow.Output.As != nil { + var err error + output, err = traverseAndEvaluate(wr.Workflow.Output.As, output, "/", wr.Context) + if err != nil { + return nil, err + } + } + if wr.Workflow.Output.Schema != nil { + if err := validateSchema(output, wr.Workflow.Output.Schema, "/"); err != nil { + return nil, err + } + } } return output, nil } diff --git a/impl/task_runner_test.go b/impl/runner_test.go similarity index 81% rename from impl/task_runner_test.go rename to impl/runner_test.go index c5a76d7..32c9c86 100644 --- a/impl/task_runner_test.go +++ b/impl/runner_test.go @@ -15,15 +15,60 @@ package impl import ( - "os" - "path/filepath" - "testing" - + "context" + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/serverlessworkflow/sdk-go/v3/parser" "github.com/stretchr/testify/assert" + "os" + "path/filepath" + "testing" ) +type taskSupportOpts func(*workflowRunnerImpl) + +// newTaskSupport returns an instance of TaskSupport for test purposes +func newTaskSupport(opts ...taskSupportOpts) TaskSupport { + wfCtx, err := ctx.NewWorkflowContext(&model.Workflow{}) + if err != nil { + panic(fmt.Errorf("failed to create workflow context within the test environment: %v", err)) + } + + ts := &workflowRunnerImpl{ + Workflow: nil, + Context: context.TODO(), + RunnerCtx: wfCtx, + } + + // Apply each functional option to ts + for _, opt := range opts { + opt(ts) + } + + return ts +} + +//nolint:unused +func withWorkflow(wf *model.Workflow) taskSupportOpts { + return func(ts *workflowRunnerImpl) { + ts.Workflow = wf + } +} + +//nolint:unused +func withContext(ctx context.Context) taskSupportOpts { + return func(ts *workflowRunnerImpl) { + ts.Context = ctx + } +} + +func withRunnerCtx(workflowContext ctx.WorkflowContext) taskSupportOpts { + return func(ts *workflowRunnerImpl) { + ts.RunnerCtx = workflowContext + } +} + // runWorkflowTest is a reusable test function for workflows func runWorkflowTest(t *testing.T, workflowPath string, input, expectedOutput map[string]interface{}) { // Run the workflow @@ -50,7 +95,8 @@ func runWorkflow(t *testing.T, workflowPath string, input, expectedOutput map[st assert.NoError(t, err, "Failed to parse workflow YAML") // Initialize the workflow runner - runner := NewDefaultRunner(workflow) + runner, err := NewDefaultRunner(workflow) + assert.NoError(t, err) // Run the workflow output, err = runner.Run(input) @@ -151,7 +197,8 @@ func TestWorkflowRunner_Run_YAML_WithSchemaValidation(t *testing.T) { 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) + runner, err := NewDefaultRunner(workflow) + assert.NoError(t, err) _, err = runner.Run(input) assert.Error(t, err, "Expected validation error for invalid input") assert.Contains(t, err.Error(), "JSON schema validation failed") @@ -178,7 +225,8 @@ func TestWorkflowRunner_Run_YAML_WithSchemaValidation(t *testing.T) { 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) + runner, err := NewDefaultRunner(workflow) + assert.NoError(t, err) _, err = runner.Run(input) assert.Error(t, err, "Expected validation error for invalid task input") assert.Contains(t, err.Error(), "JSON schema validation failed") @@ -205,7 +253,8 @@ func TestWorkflowRunner_Run_YAML_WithSchemaValidation(t *testing.T) { 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) + runner, err := NewDefaultRunner(workflow) + assert.NoError(t, err) _, err = runner.Run(input) assert.Error(t, err, "Expected validation error for invalid task output") assert.Contains(t, err.Error(), "JSON schema validation failed") @@ -266,9 +315,12 @@ func TestWorkflowRunner_Run_YAML_ControlFlow(t *testing.T) { 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 Inline Error", func(t *testing.T) { + runWorkflowWithErr(t, "./testdata/raise_inline.yaml", nil, nil, func(err error) { + assert.Equal(t, model.ErrorTypeValidation, model.AsError(err).Type.String()) + assert.Equal(t, "Invalid input provided to workflow raise-inline", model.AsError(err).Detail.String()) + }) + }) t.Run("Raise Referenced Error", func(t *testing.T) { runWorkflowWithErr(t, "./testdata/raise_reusable.yaml", nil, nil, @@ -312,7 +364,6 @@ func TestWorkflowRunner_Run_YAML_RaiseTasks_ControlFlow(t *testing.T) { } 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{}{ @@ -320,8 +371,36 @@ func TestForTaskRunner_Run(t *testing.T) { } expectedOutput := map[string]interface{}{ "processed": map[string]interface{}{ - "colors": []string{"red", "green", "blue"}, - "indexed": []float64{0, 1, 2}, + "colors": []interface{}{"red", "green", "blue"}, + "indexes": []interface{}{0, 1, 2}, + }, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("SUM Numbers", func(t *testing.T) { + workflowPath := "./testdata/for_sum_numbers.yaml" + input := map[string]interface{}{ + "numbers": []int32{2, 3, 4}, + } + expectedOutput := map[string]interface{}{ + "result": interface{}(9), + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("For Nested Loops", func(t *testing.T) { + workflowPath := "./testdata/for_nested_loops.yaml" + input := map[string]interface{}{ + "fruits": []interface{}{"apple", "banana"}, + "colors": []interface{}{"red", "green"}, + } + expectedOutput := map[string]interface{}{ + "matrix": []interface{}{ + []interface{}{"apple", "red"}, + []interface{}{"apple", "green"}, + []interface{}{"banana", "red"}, + []interface{}{"banana", "green"}, }, } runWorkflowTest(t, workflowPath, input, expectedOutput) diff --git a/impl/task_runner.go b/impl/task_runner.go index 05d3817..a302bca 100644 --- a/impl/task_runner.go +++ b/impl/task_runner.go @@ -15,238 +15,41 @@ package impl import ( - "fmt" - "reflect" - "strings" - - "github.com/serverlessworkflow/sdk-go/v3/expr" + "context" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" + "time" ) var _ TaskRunner = &SetTaskRunner{} var _ TaskRunner = &RaiseTaskRunner{} var _ TaskRunner = &ForTaskRunner{} +var _ TaskRunner = &DoTaskRunner{} type TaskRunner interface { Run(input interface{}) (interface{}, error) GetTaskName() string } -func NewSetTaskRunner(taskName string, task *model.SetTask) (*SetTaskRunner, error) { - if task == nil || task.Set == nil { - return nil, model.NewErrValidation(fmt.Errorf("no set configuration provided for SetTask %s", taskName), taskName) - } - return &SetTaskRunner{ - Task: task, - TaskName: taskName, - }, nil -} - -type SetTaskRunner struct { - Task *model.SetTask - TaskName string -} - -func (s *SetTaskRunner) GetTaskName() string { - return s.TaskName -} - -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, model.NewErrExpression(err, s.TaskName) - } - - output, ok := result.(map[string]interface{}) - if !ok { - 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 -} - -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{ - Task: task, - TaskName: taskName, - }, 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 -} - -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 -} - -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 -} - -const ( - forTaskDefaultEach = "$item" - forTaskDefaultAt = "$index" -) - -type ForTaskRunner struct { - Task *model.ForTask - TaskName string - DoRunner *DoTaskRunner -} - -func (f *ForTaskRunner) Run(input interface{}) (interface{}, error) { - 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 { - return f.TaskName +type TaskSupport interface { + SetTaskStatus(task string, status ctx.StatusPhase) + GetWorkflowDef() *model.Workflow + // SetWorkflowInstanceCtx is the `$context` variable accessible in JQ expressions and set in `export.as` + SetWorkflowInstanceCtx(value interface{}) + // GetContext gets the sharable Workflow context. Accessible via ctx.GetWorkflowContext. + GetContext() context.Context + SetTaskRawInput(value interface{}) + SetTaskRawOutput(value interface{}) + SetTaskDef(task model.Task) error + SetTaskStartedAt(value time.Time) + SetTaskName(name string) + // SetTaskReferenceFromName based on the taskName and the model.Workflow definition, set the JSON Pointer reference to the context + SetTaskReferenceFromName(taskName string) error + GetTaskReference() string + // SetLocalExprVars overrides local variables in expression processing + SetLocalExprVars(vars map[string]interface{}) + // AddLocalExprVars adds to the local variables in expression processing. Won't override previous entries. + AddLocalExprVars(vars map[string]interface{}) + // RemoveLocalExprVars removes local variables added in AddLocalExprVars or SetLocalExprVars + RemoveLocalExprVars(keys ...string) } diff --git a/impl/task_runner_do.go b/impl/task_runner_do.go index a34a4dd..75249b1 100644 --- a/impl/task_runner_do.go +++ b/impl/task_runner_do.go @@ -16,27 +16,18 @@ package impl import ( "fmt" - - "github.com/serverlessworkflow/sdk-go/v3/expr" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" + "time" ) -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) + return NewSetTaskRunner(taskName, t, taskSupport) case *model.RaiseTask: - return NewRaiseTaskRunner(taskName, t, taskSupport.GetWorkflowDef()) + return NewRaiseTaskRunner(taskName, t, taskSupport) case *model.DoTask: return NewDoTaskRunner(t.Do, taskSupport) case *model.ForTask: @@ -62,15 +53,15 @@ func (d *DoTaskRunner) Run(input interface{}) (output interface{}, err error) { if d.TaskList == nil { return input, nil } - return d.executeTasks(input, d.TaskList) + return d.runTasks(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) { +// runTasks runs all defined tasks sequentially. +func (d *DoTaskRunner) runTasks(input interface{}, tasks *model.TaskList) (output interface{}, err error) { output = input if tasks == nil { return output, nil @@ -78,9 +69,15 @@ func (d *DoTaskRunner) executeTasks(input interface{}, tasks *model.TaskList) (o idx := 0 currentTask := (*tasks)[idx] - ctx := d.TaskSupport.GetTaskContext() for currentTask != nil { + if err = d.TaskSupport.SetTaskDef(currentTask); err != nil { + return nil, err + } + if err = d.TaskSupport.SetTaskReferenceFromName(currentTask.Key); err != nil { + return nil, err + } + if shouldRun, err := d.shouldRunTask(input, currentTask); err != nil { return output, err } else if !shouldRun { @@ -88,19 +85,19 @@ func (d *DoTaskRunner) executeTasks(input interface{}, tasks *model.TaskList) (o continue } - ctx.SetTaskStatus(currentTask.Key, PendingStatus) + d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.PendingStatus) runner, err := NewTaskRunner(currentTask.Key, currentTask.Task, d.TaskSupport) if err != nil { return output, err } - ctx.SetTaskStatus(currentTask.Key, RunningStatus) + d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.RunningStatus) if output, err = d.runTask(input, runner, currentTask.Task.GetBase()); err != nil { - ctx.SetTaskStatus(currentTask.Key, FaultedStatus) + d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.FaultedStatus) return output, err } - ctx.SetTaskStatus(currentTask.Key, CompletedStatus) + d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.CompletedStatus) input = deepCloneValue(output) idx, currentTask = tasks.Next(idx) } @@ -110,13 +107,11 @@ func (d *DoTaskRunner) executeTasks(input interface{}, tasks *model.TaskList) (o 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) + output, err := traverseAndEvaluateBool(task.GetBase().If.String(), input, d.TaskSupport.GetContext()) if err != nil { return false, model.NewErrExpression(err, task.Key) } - if result, ok := output.(bool); ok && !result { - return false, nil - } + return output, nil } return true, nil } @@ -125,6 +120,10 @@ func (d *DoTaskRunner) shouldRunTask(input interface{}, task *model.TaskItem) (b func (d *DoTaskRunner) runTask(input interface{}, runner TaskRunner, task *model.TaskBase) (output interface{}, err error) { taskName := runner.GetTaskName() + d.TaskSupport.SetTaskStartedAt(time.Now()) + d.TaskSupport.SetTaskRawInput(input) + d.TaskSupport.SetTaskName(taskName) + if task.Input != nil { if input, err = d.processTaskInput(task, input, taskName); err != nil { return nil, err @@ -136,10 +135,16 @@ func (d *DoTaskRunner) runTask(input interface{}, runner TaskRunner, task *model return nil, err } + d.TaskSupport.SetTaskRawOutput(output) + if output, err = d.processTaskOutput(task, output, taskName); err != nil { return nil, err } + if err = d.processTaskExport(task, output, taskName); err != nil { + return nil, err + } + return output, nil } @@ -153,7 +158,7 @@ func (d *DoTaskRunner) processTaskInput(task *model.TaskBase, taskInput interfac return nil, err } - if output, err = traverseAndEvaluate(task.Input.From, taskInput, taskName); err != nil { + if output, err = traverseAndEvaluate(task.Input.From, taskInput, taskName, d.TaskSupport.GetContext()); err != nil { return nil, err } @@ -166,7 +171,7 @@ func (d *DoTaskRunner) processTaskOutput(task *model.TaskBase, taskOutput interf return taskOutput, nil } - if output, err = traverseAndEvaluate(task.Output.As, taskOutput, taskName); err != nil { + if output, err = traverseAndEvaluate(task.Output.As, taskOutput, taskName, d.TaskSupport.GetContext()); err != nil { return nil, err } @@ -176,3 +181,22 @@ func (d *DoTaskRunner) processTaskOutput(task *model.TaskBase, taskOutput interf return output, nil } + +func (d *DoTaskRunner) processTaskExport(task *model.TaskBase, taskOutput interface{}, taskName string) (err error) { + if task.Export == nil { + return nil + } + + output, err := traverseAndEvaluate(task.Export.As, taskOutput, taskName, d.TaskSupport.GetContext()) + if err != nil { + return err + } + + if err = validateSchema(output, task.Export.Schema, taskName); err != nil { + return nil + } + + d.TaskSupport.SetWorkflowInstanceCtx(output) + + return nil +} diff --git a/impl/task_runner_for.go b/impl/task_runner_for.go new file mode 100644 index 0000000..825e7f6 --- /dev/null +++ b/impl/task_runner_for.go @@ -0,0 +1,135 @@ +// 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 ( + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/impl/expr" + "github.com/serverlessworkflow/sdk-go/v3/model" + "reflect" + "strings" +) + +const ( + forTaskDefaultEach = "$item" + forTaskDefaultAt = "$index" +) + +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, + TaskSupport: taskSupport, + }, nil +} + +type ForTaskRunner struct { + Task *model.ForTask + TaskName string + DoRunner *DoTaskRunner + TaskSupport TaskSupport +} + +func (f *ForTaskRunner) Run(input interface{}) (interface{}, error) { + defer func() { + // clear local variables + f.TaskSupport.RemoveLocalExprVars(f.Task.For.Each, f.Task.For.At) + }() + f.sanitizeFor() + in, err := expr.TraverseAndEvaluate(f.Task.For.In, input, f.TaskSupport.GetContext()) + if err != nil { + return nil, err + } + + forOutput := input + 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 + } + if f.Task.While != "" { + whileIsTrue, err := traverseAndEvaluateBool(f.Task.While, forOutput, f.TaskSupport.GetContext()) + if err != nil { + return nil, err + } + if !whileIsTrue { + break + } + } + } + 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) { + forVars := map[string]interface{}{ + f.Task.For.At: idx, + f.Task.For.Each: item, + } + // Instead of Set, we Add since other tasks in this very same context might be adding variables to the context + f.TaskSupport.AddLocalExprVars(forVars) + // output from previous iterations are merged together + var err error + forOutput, err = f.DoRunner.Run(forOutput) + 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 { + return f.TaskName +} diff --git a/impl/task_runner_raise.go b/impl/task_runner_raise.go new file mode 100644 index 0000000..46014a5 --- /dev/null +++ b/impl/task_runner_raise.go @@ -0,0 +1,105 @@ +// 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 ( + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +func NewRaiseTaskRunner(taskName string, task *model.RaiseTask, taskSupport TaskSupport) (*RaiseTaskRunner, error) { + if err := resolveErrorDefinition(task, taskSupport.GetWorkflowDef()); 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{ + Task: task, + TaskName: taskName, + TaskSupport: taskSupport, + }, 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 + TaskSupport TaskSupport +} + +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, r.TaskSupport.GetContext()) + if err != nil { + return nil, err + } + + var titleResult interface{} + titleResult, err = traverseAndEvaluate(r.Task.Raise.Error.Definition.Title.AsObjectOrRuntimeExpr(), input, r.TaskName, r.TaskSupport.GetContext()) + if err != nil { + return nil, err + } + + instance := r.TaskSupport.GetTaskReference() + + var raiseErr *model.Error + if raiseErrF, ok := raiseErrFuncMapping[r.Task.Raise.Error.Definition.Type.String()]; ok { + raiseErr = raiseErrF(fmt.Errorf("%v", detailResult), instance) + } else { + raiseErr = r.Task.Raise.Error.Definition + raiseErr.Detail = model.NewStringOrRuntimeExpr(fmt.Sprintf("%v", detailResult)) + raiseErr.Instance = &model.JsonPointerOrRuntimeExpression{Value: 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_runner_raise_test.go b/impl/task_runner_raise_test.go index 3527283..e85ac28 100644 --- a/impl/task_runner_raise_test.go +++ b/impl/task_runner_raise_test.go @@ -17,6 +17,7 @@ package impl import ( "encoding/json" "errors" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "testing" "github.com/serverlessworkflow/sdk-go/v3/model" @@ -39,7 +40,11 @@ func TestRaiseTaskRunner_WithDefinedError(t *testing.T) { }, } - runner, err := NewRaiseTaskRunner("task_raise_defined", raiseTask, nil) + wfCtx, err := ctx.NewWorkflowContext(&model.Workflow{}) + assert.NoError(t, err) + wfCtx.SetTaskReference("task_raise_defined") + + runner, err := NewRaiseTaskRunner("task_raise_defined", raiseTask, newTaskSupport(withRunnerCtx(wfCtx))) assert.NoError(t, err) output, err := runner.Run(input) @@ -70,7 +75,7 @@ func TestRaiseTaskRunner_WithReferencedError(t *testing.T) { }, } - runner, err := NewRaiseTaskRunner("task_raise_ref", raiseTask, nil) + runner, err := NewRaiseTaskRunner("task_raise_ref", raiseTask, newTaskSupport()) assert.Error(t, err) assert.Nil(t, runner) } @@ -93,7 +98,11 @@ func TestRaiseTaskRunner_TimeoutErrorWithExpression(t *testing.T) { }, } - runner, err := NewRaiseTaskRunner("task_raise_timeout_expr", raiseTask, nil) + wfCtx, err := ctx.NewWorkflowContext(&model.Workflow{}) + assert.NoError(t, err) + wfCtx.SetTaskReference("task_raise_timeout_expr") + + runner, err := NewRaiseTaskRunner("task_raise_timeout_expr", raiseTask, newTaskSupport(withRunnerCtx(wfCtx))) assert.NoError(t, err) output, err := runner.Run(input) diff --git a/impl/task_runner_set.go b/impl/task_runner_set.go new file mode 100644 index 0000000..295a5f2 --- /dev/null +++ b/impl/task_runner_set.go @@ -0,0 +1,56 @@ +// 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 ( + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +func NewSetTaskRunner(taskName string, task *model.SetTask, taskSupport TaskSupport) (*SetTaskRunner, error) { + if task == nil || task.Set == nil { + return nil, model.NewErrValidation(fmt.Errorf("no set configuration provided for SetTask %s", taskName), taskName) + } + return &SetTaskRunner{ + Task: task, + TaskName: taskName, + TaskSupport: taskSupport, + }, nil +} + +type SetTaskRunner struct { + Task *model.SetTask + TaskName string + TaskSupport TaskSupport +} + +func (s *SetTaskRunner) GetTaskName() string { + return s.TaskName +} + +func (s *SetTaskRunner) Run(input interface{}) (output interface{}, err error) { + setObject := deepClone(s.Task.Set) + result, err := traverseAndEvaluate(model.NewObjectOrRuntimeExpr(setObject), input, s.TaskName, s.TaskSupport.GetContext()) + if err != nil { + return nil, err + } + + output, ok := result.(map[string]interface{}) + if !ok { + 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 48ca18b..c1d5534 100644 --- a/impl/task_set_test.go +++ b/impl/task_set_test.go @@ -45,7 +45,7 @@ func TestSetTaskExecutor_Exec(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task1", setTask) + executor, err := NewSetTaskRunner("task1", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -79,7 +79,7 @@ func TestSetTaskExecutor_StaticValues(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_static", setTask) + executor, err := NewSetTaskRunner("task_static", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -109,7 +109,7 @@ func TestSetTaskExecutor_RuntimeExpressions(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_runtime_expr", setTask) + executor, err := NewSetTaskRunner("task_runtime_expr", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -141,7 +141,7 @@ func TestSetTaskExecutor_NestedStructures(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_nested_structures", setTask) + executor, err := NewSetTaskRunner("task_nested_structures", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -176,7 +176,7 @@ func TestSetTaskExecutor_StaticAndDynamicValues(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_static_dynamic", setTask) + executor, err := NewSetTaskRunner("task_static_dynamic", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -201,7 +201,7 @@ func TestSetTaskExecutor_MissingInputData(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_missing_input", setTask) + executor, err := NewSetTaskRunner("task_missing_input", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -220,7 +220,7 @@ func TestSetTaskExecutor_ExpressionsWithFunctions(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_expr_functions", setTask) + executor, err := NewSetTaskRunner("task_expr_functions", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -246,7 +246,7 @@ func TestSetTaskExecutor_ConditionalExpressions(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_conditional_expr", setTask) + executor, err := NewSetTaskRunner("task_conditional_expr", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -273,7 +273,7 @@ func TestSetTaskExecutor_ArrayDynamicIndex(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_array_indexing", setTask) + executor, err := NewSetTaskRunner("task_array_indexing", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -299,7 +299,7 @@ func TestSetTaskExecutor_NestedConditionalLogic(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_nested_condition", setTask) + executor, err := NewSetTaskRunner("task_nested_condition", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -323,7 +323,7 @@ func TestSetTaskExecutor_DefaultValues(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_default_values", setTask) + executor, err := NewSetTaskRunner("task_default_values", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -363,7 +363,7 @@ func TestSetTaskExecutor_ComplexNestedStructures(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_complex_nested", setTask) + executor, err := NewSetTaskRunner("task_complex_nested", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) @@ -399,7 +399,7 @@ func TestSetTaskExecutor_MultipleExpressions(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_multiple_expr", setTask) + executor, err := NewSetTaskRunner("task_multiple_expr", setTask, newTaskSupport()) assert.NoError(t, err) output, err := executor.Run(input) diff --git a/impl/testdata/for_nested_loops.yaml b/impl/testdata/for_nested_loops.yaml new file mode 100644 index 0000000..3bef556 --- /dev/null +++ b/impl/testdata/for_nested_loops.yaml @@ -0,0 +1,35 @@ +# 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: for-tests + name: nested-loops + version: '1.0.0' +do: + - outerLoop: + for: + in: ${ .fruits } + each: fruit + at: fruitIdx + do: + - innerLoop: + for: + in: ${ $input.colors } + each: color + at: colorIdx + do: + - combinePair: + set: + matrix: ${ .matrix + [[$fruit, $color]] } diff --git a/impl/testdata/for_sum_numbers.yaml b/impl/testdata/for_sum_numbers.yaml new file mode 100644 index 0000000..afc81e9 --- /dev/null +++ b/impl/testdata/for_sum_numbers.yaml @@ -0,0 +1,30 @@ +# 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: for-tests + name: sum-numbers + version: '1.0.0' +do: + - sumLoop: + for: + in: ${ .numbers } + do: + - addNumber: + set: + total: ${ .total + $item } + - finalize: + set: + result: ${ .total } diff --git a/impl/testdata/raise_inline.yaml b/impl/testdata/raise_inline.yaml index c464877..940528a 100644 --- a/impl/testdata/raise_inline.yaml +++ b/impl/testdata/raise_inline.yaml @@ -24,4 +24,4 @@ do: 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 )'" } + detail: ${ "Invalid input provided to workflow \($workflow.definition.document.name)" } diff --git a/impl/utils.go b/impl/utils.go index 2cdf952..20b2360 100644 --- a/impl/utils.go +++ b/impl/utils.go @@ -15,7 +15,8 @@ package impl import ( - "github.com/serverlessworkflow/sdk-go/v3/expr" + "context" + "github.com/serverlessworkflow/sdk-go/v3/impl/expr" "github.com/serverlessworkflow/sdk-go/v3/model" ) @@ -51,31 +52,27 @@ func validateSchema(data interface{}, schema *model.Schema, taskName string) err return nil } -func traverseAndEvaluate(runtimeExpr *model.ObjectOrRuntimeExpr, input interface{}, taskName string) (output interface{}, err error) { +func traverseAndEvaluate(runtimeExpr *model.ObjectOrRuntimeExpr, input interface{}, taskName string, wfCtx context.Context) (output interface{}, err error) { if runtimeExpr == nil { return input, nil } - output, err = expr.TraverseAndEvaluate(runtimeExpr.AsStringOrMap(), input) + output, err = expr.TraverseAndEvaluate(runtimeExpr.AsStringOrMap(), input, wfCtx) if err != nil { return nil, model.NewErrExpression(err, taskName) } 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 - } +func traverseAndEvaluateBool(runtimeExpr string, input interface{}, wfCtx context.Context) (bool, error) { + if len(runtimeExpr) == 0 { + return false, nil } - - if transformation != nil { - transformed, err := traverseAndEvaluate(transformation, data, taskName) - if err != nil { - return nil, err - } - return transformed, nil + output, err := expr.TraverseAndEvaluate(runtimeExpr, input, wfCtx) + if err != nil { + return false, nil } - - return data, nil + if result, ok := output.(bool); ok { + return result, nil + } + return false, nil } diff --git a/model/errors.go b/model/errors.go index eeef71c..9700f17 100644 --- a/model/errors.go +++ b/model/errors.go @@ -18,7 +18,6 @@ import ( "encoding/json" "errors" "fmt" - "reflect" "strings" ) @@ -77,10 +76,10 @@ func (e *Error) WithInstanceRef(workflow *Workflow, taskName string) *Error { } // Generate a JSON pointer reference for the task within the workflow - instance, pointerErr := GenerateJSONPointer(workflow, taskName) - if pointerErr == nil { - e.Instance = &JsonPointerOrRuntimeExpression{Value: instance} - } + //instance, pointerErr := GenerateJSONPointer(workflow, taskName) + //if pointerErr == nil { + // e.Instance = &JsonPointerOrRuntimeExpression{Value: instance} + //} // TODO: log the pointer error return e @@ -268,57 +267,3 @@ func ErrorFromJSON(jsonStr string) (*Error, error) { } // 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: %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: %w", 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/objects.go b/model/objects.go index d79ac55..2bb8dd9 100644 --- a/model/objects.go +++ b/model/objects.go @@ -73,6 +73,12 @@ type ObjectOrRuntimeExpr struct { Value interface{} `json:"-" validate:"object_or_runtime_expr"` // Custom validation tag. } +func NewObjectOrRuntimeExpr(value interface{}) *ObjectOrRuntimeExpr { + return &ObjectOrRuntimeExpr{ + Value: value, + } +} + func (o *ObjectOrRuntimeExpr) String() string { return fmt.Sprintf("%v", o.Value) } diff --git a/model/runtime_expression.go b/model/runtime_expression.go index 6a056cb..ae04e46 100644 --- a/model/runtime_expression.go +++ b/model/runtime_expression.go @@ -17,8 +17,8 @@ package model import ( "encoding/json" "fmt" - - "github.com/serverlessworkflow/sdk-go/v3/expr" + "github.com/itchyny/gojq" + "strings" ) // RuntimeExpression represents a runtime expression. @@ -34,9 +34,34 @@ func NewExpr(runtimeExpression string) *RuntimeExpression { return &RuntimeExpression{Value: runtimeExpression} } +// IsStrictExpr returns true if the string is enclosed in `${ }` +func IsStrictExpr(expression string) bool { + return strings.HasPrefix(expression, "${") && strings.HasSuffix(expression, "}") +} + +// SanitizeExpr processes the expression to ensure it's ready for evaluation +// It removes `${}` if present and replaces single quotes with double quotes +func SanitizeExpr(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 +} + +func IsValidExpr(expression string) bool { + expression = SanitizeExpr(expression) + _, err := gojq.Parse(expression) + return err == nil +} + // IsValid checks if the RuntimeExpression value is valid, handling both with and without `${}`. func (r *RuntimeExpression) IsValid() bool { - return expr.IsValid(r.Value) + return IsValidExpr(r.Value) } // UnmarshalJSON implements custom unmarshalling for RuntimeExpression. diff --git a/model/runtime_expression_test.go b/model/runtime_expression_test.go index 296e1de..770af70 100644 --- a/model/runtime_expression_test.go +++ b/model/runtime_expression_test.go @@ -68,3 +68,152 @@ func TestRuntimeExpressionUnmarshalJSON(t *testing.T) { type RuntimeExpressionAcme struct { Expression RuntimeExpression `json:"expression"` } + +func TestIsStrictExpr(t *testing.T) { + tests := []struct { + name string + expression string + want bool + }{ + { + name: "StrictExpr with braces", + expression: "${.some.path}", + want: true, + }, + { + name: "Missing closing brace", + expression: "${.some.path", + want: false, + }, + { + name: "Missing opening brace", + expression: ".some.path}", + want: false, + }, + { + name: "Empty string", + expression: "", + want: false, + }, + { + name: "No braces at all", + expression: ".some.path", + want: false, + }, + { + name: "With spaces but still correct", + expression: "${ .some.path }", + want: true, + }, + { + name: "Only braces", + expression: "${}", + want: true, // Technically matches prefix+suffix + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := IsStrictExpr(tc.expression) + if got != tc.want { + t.Errorf("IsStrictExpr(%q) = %v, want %v", tc.expression, got, tc.want) + } + }) + } +} + +func TestSanitize(t *testing.T) { + tests := []struct { + name string + expression string + want string + }{ + { + name: "Remove braces and replace single quotes", + expression: "${ 'some.path' }", + want: `"some.path"`, + }, + { + name: "Already sanitized string, no braces", + expression: ".some.path", + want: ".some.path", + }, + { + name: "Multiple single quotes", + expression: "${ 'foo' + 'bar' }", + want: `"foo" + "bar"`, + }, + { + name: "Only braces with spaces", + expression: "${ }", + want: "", + }, + { + name: "No braces, just single quotes to be replaced", + expression: "'some.path'", + want: `"some.path"`, + }, + { + name: "Nothing to sanitize", + expression: "", + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := SanitizeExpr(tc.expression) + if got != tc.want { + t.Errorf("Sanitize(%q) = %q, want %q", tc.expression, got, tc.want) + } + }) + } +} + +func TestIsValid(t *testing.T) { + tests := []struct { + name string + expression string + want bool + }{ + { + name: "Valid expression - simple path", + expression: "${ .foo }", + want: true, + }, + { + name: "Valid expression - array slice", + expression: "${ .arr[0] }", + want: true, + }, + { + name: "Invalid syntax", + expression: "${ .foo( }", + want: false, + }, + { + name: "No braces but valid JQ (directly provided)", + expression: ".bar", + want: true, + }, + { + name: "Empty expression", + expression: "", + want: true, // empty is parseable but yields an empty query + }, + { + name: "Invalid bracket usage", + expression: "${ .arr[ }", + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := IsValidExpr(tc.expression) + if got != tc.want { + t.Errorf("IsValid(%q) = %v, want %v", tc.expression, got, tc.want) + } + }) + } +} diff --git a/model/workflow.go b/model/workflow.go index 313a9e5..15dba7e 100644 --- a/model/workflow.go +++ b/model/workflow.go @@ -31,6 +31,20 @@ type Workflow struct { Schedule *Schedule `json:"schedule,omitempty" yaml:"schedule,omitempty"` } +// AsMap converts the Workflow struct into a JSON Map object. +func (w *Workflow) AsMap() (map[string]interface{}, error) { + jsonBytes, err := json.Marshal(w) + if err != nil { + return nil, err + } + + var m map[string]interface{} + if err = json.Unmarshal(jsonBytes, &m); err != nil { + return nil, err + } + return m, nil +} + func (w *Workflow) MarshalYAML() (interface{}, error) { // Create a map to hold fields data := map[string]interface{}{ From 45bb41e088f64f765a25ee6935339fe72b66bf28 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Mon, 31 Mar 2025 13:37:26 -0300 Subject: [PATCH 04/11] Fix #233 - Add support to 'switch' task (#234) * Fix #233 - Add support to 'switch' task Signed-off-by: Ricardo Zanini * Fix headers and linters Signed-off-by: Ricardo Zanini --------- Signed-off-by: Ricardo Zanini --- README.md | 4 +- impl/ctx/context.go | 5 ++- impl/expr/expr.go | 1 + impl/json_pointer.go | 3 +- impl/json_pointer_test.go | 11 +++--- impl/runner.go | 3 +- impl/runner_test.go | 55 ++++++++++++++++++++++++-- impl/task_runner.go | 3 +- impl/task_runner_do.go | 47 +++++++++++++++++++++- impl/task_runner_for.go | 5 ++- impl/task_runner_raise.go | 1 + impl/task_runner_raise_test.go | 3 +- impl/task_runner_set.go | 1 + impl/testdata/switch_match.yaml | 43 ++++++++++++++++++++ impl/testdata/switch_with_default.yaml | 43 ++++++++++++++++++++ impl/utils.go | 1 + model/runtime_expression.go | 11 +++++- 17 files changed, 220 insertions(+), 20 deletions(-) create mode 100644 impl/testdata/switch_match.yaml create mode 100644 impl/testdata/switch_with_default.yaml diff --git a/README.md b/README.md index f05e54c..1a6654e 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ The table below lists the current state of this implementation. This table is a | Task Raise | βœ… | | Task Run | ❌ | | Task Set | βœ… | -| Task Switch | ❌ | +| Task Switch | βœ… | | Task Try | ❌ | | Task Wait | ❌ | | Lifecycle Events | 🟑 | @@ -157,7 +157,7 @@ The table below lists the current state of this implementation. This table is a | AsyncAPI Server | ❌ | | AsyncAPI Outbound Message | ❌ | | AsyncAPI Subscription | ❌ | -| Workflow Definition Reference | ❌ | +| 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. diff --git a/impl/ctx/context.go b/impl/ctx/context.go index 1f0d716..f013507 100644 --- a/impl/ctx/context.go +++ b/impl/ctx/context.go @@ -19,10 +19,11 @@ import ( "encoding/json" "errors" "fmt" - "github.com/google/uuid" - "github.com/serverlessworkflow/sdk-go/v3/model" "sync" "time" + + "github.com/google/uuid" + "github.com/serverlessworkflow/sdk-go/v3/model" ) var ErrWorkflowContextNotFound = errors.New("workflow context not found") diff --git a/impl/expr/expr.go b/impl/expr/expr.go index 03d558e..60e2765 100644 --- a/impl/expr/expr.go +++ b/impl/expr/expr.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "github.com/itchyny/gojq" "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" diff --git a/impl/json_pointer.go b/impl/json_pointer.go index 4d276ff..dedaaf3 100644 --- a/impl/json_pointer.go +++ b/impl/json_pointer.go @@ -17,9 +17,10 @@ package impl import ( "encoding/json" "fmt" - "github.com/serverlessworkflow/sdk-go/v3/model" "reflect" "strings" + + "github.com/serverlessworkflow/sdk-go/v3/model" ) func findJsonPointer(data interface{}, target string, path string) (string, bool) { diff --git a/impl/json_pointer_test.go b/impl/json_pointer_test.go index 76077bc..aeec1e4 100644 --- a/impl/json_pointer_test.go +++ b/impl/json_pointer_test.go @@ -15,9 +15,10 @@ package impl import ( + "testing" + "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/stretchr/testify/assert" - "testing" ) // TestGenerateJSONPointer_SimpleTask tests a simple workflow task. @@ -60,8 +61,8 @@ func TestGenerateJSONPointer_ForkTask(t *testing.T) { Fork: model.ForkTaskConfiguration{ Compete: true, Branches: &model.TaskList{ - {Key: "callNurse", Task: &model.CallHTTP{Call: "http", With: model.HTTPArguments{Method: "put", Endpoint: model.NewEndpoint("https://hospital.com/api/alert/nurses")}}}, - {Key: "callDoctor", Task: &model.CallHTTP{Call: "http", With: model.HTTPArguments{Method: "put", Endpoint: model.NewEndpoint("https://hospital.com/api/alert/doctor")}}}, + &model.TaskItem{Key: "callNurse", Task: &model.CallHTTP{Call: "http", With: model.HTTPArguments{Method: "put", Endpoint: model.NewEndpoint("https://hospital.com/api/alert/nurses")}}}, + &model.TaskItem{Key: "callDoctor", Task: &model.CallHTTP{Call: "http", With: model.HTTPArguments{Method: "put", Endpoint: model.NewEndpoint("https://hospital.com/api/alert/doctor")}}}, }, }, }, @@ -85,12 +86,12 @@ func TestGenerateJSONPointer_DeepNestedTask(t *testing.T) { Fork: model.ForkTaskConfiguration{ Compete: false, Branches: &model.TaskList{ - { + &model.TaskItem{ Key: "branchA", Task: &model.ForkTask{ Fork: model.ForkTaskConfiguration{ Branches: &model.TaskList{ - { + &model.TaskItem{ Key: "deepTask", Task: &model.SetTask{Set: map[string]interface{}{"result": "done"}}, }, diff --git a/impl/runner.go b/impl/runner.go index 1c9ad8b..5328ee3 100644 --- a/impl/runner.go +++ b/impl/runner.go @@ -17,9 +17,10 @@ package impl import ( "context" "fmt" + "time" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" - "time" ) var _ WorkflowRunner = &workflowRunnerImpl{} diff --git a/impl/runner_test.go b/impl/runner_test.go index 32c9c86..9bb599c 100644 --- a/impl/runner_test.go +++ b/impl/runner_test.go @@ -17,13 +17,14 @@ package impl import ( "context" "fmt" + "os" + "path/filepath" + "testing" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/serverlessworkflow/sdk-go/v3/parser" "github.com/stretchr/testify/assert" - "os" - "path/filepath" - "testing" ) type taskSupportOpts func(*workflowRunnerImpl) @@ -407,3 +408,51 @@ func TestForTaskRunner_Run(t *testing.T) { }) } + +func TestSwitchTaskRunner_Run(t *testing.T) { + t.Run("Color is red", func(t *testing.T) { + workflowPath := "./testdata/switch_match.yaml" + input := map[string]interface{}{ + "color": "red", + } + expectedOutput := map[string]interface{}{ + "colors": []interface{}{"red"}, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("Color is green", func(t *testing.T) { + workflowPath := "./testdata/switch_match.yaml" + input := map[string]interface{}{ + "color": "green", + } + expectedOutput := map[string]interface{}{ + "colors": []interface{}{"green"}, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("Color is blue", func(t *testing.T) { + workflowPath := "./testdata/switch_match.yaml" + input := map[string]interface{}{ + "color": "blue", + } + expectedOutput := map[string]interface{}{ + "colors": []interface{}{"blue"}, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) +} + +func TestSwitchTaskRunner_DefaultCase(t *testing.T) { + t.Run("Color is unknown, should match default", func(t *testing.T) { + workflowPath := "./testdata/switch_with_default.yaml" + input := map[string]interface{}{ + "color": "yellow", + } + expectedOutput := map[string]interface{}{ + "colors": []interface{}{"default"}, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) +} diff --git a/impl/task_runner.go b/impl/task_runner.go index a302bca..6d9069d 100644 --- a/impl/task_runner.go +++ b/impl/task_runner.go @@ -16,9 +16,10 @@ package impl import ( "context" + "time" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" - "time" ) var _ TaskRunner = &SetTaskRunner{} diff --git a/impl/task_runner_do.go b/impl/task_runner_do.go index 75249b1..81ef374 100644 --- a/impl/task_runner_do.go +++ b/impl/task_runner_do.go @@ -16,9 +16,10 @@ package impl import ( "fmt" + "time" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" - "time" ) // NewTaskRunner creates a TaskRunner instance based on the task type. @@ -86,6 +87,24 @@ func (d *DoTaskRunner) runTasks(input interface{}, tasks *model.TaskList) (outpu } d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.PendingStatus) + + // Check if this task is a SwitchTask and handle it + if switchTask, ok := currentTask.Task.(*model.SwitchTask); ok { + flowDirective, err := d.evaluateSwitchTask(input, currentTask.Key, switchTask) + if err != nil { + d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.FaultedStatus) + return output, err + } + d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.CompletedStatus) + + // Process FlowDirective: update idx/currentTask accordingly + idx, currentTask = tasks.KeyAndIndex(flowDirective.Value) + if currentTask == nil { + return nil, fmt.Errorf("flow directive target '%s' not found", flowDirective.Value) + } + continue + } + runner, err := NewTaskRunner(currentTask.Key, currentTask.Task, d.TaskSupport) if err != nil { return output, err @@ -116,6 +135,32 @@ func (d *DoTaskRunner) shouldRunTask(input interface{}, task *model.TaskItem) (b return true, nil } +func (d *DoTaskRunner) evaluateSwitchTask(input interface{}, taskKey string, switchTask *model.SwitchTask) (*model.FlowDirective, error) { + var defaultThen *model.FlowDirective + for _, switchItem := range switchTask.Switch { + for _, switchCase := range switchItem { + if switchCase.When == nil { + defaultThen = switchCase.Then + continue + } + result, err := traverseAndEvaluateBool(model.NormalizeExpr(switchCase.When.String()), input, d.TaskSupport.GetContext()) + if err != nil { + return nil, model.NewErrExpression(err, taskKey) + } + if result { + if switchCase.Then == nil { + return nil, model.NewErrExpression(fmt.Errorf("missing 'then' directive in matched switch case"), taskKey) + } + return switchCase.Then, nil + } + } + } + if defaultThen != nil { + return defaultThen, nil + } + return nil, model.NewErrExpression(fmt.Errorf("no matching switch case"), taskKey) +} + // runTask executes an individual task. func (d *DoTaskRunner) runTask(input interface{}, runner TaskRunner, task *model.TaskBase) (output interface{}, err error) { taskName := runner.GetTaskName() diff --git a/impl/task_runner_for.go b/impl/task_runner_for.go index 825e7f6..fb7bcff 100644 --- a/impl/task_runner_for.go +++ b/impl/task_runner_for.go @@ -16,10 +16,11 @@ package impl import ( "fmt" - "github.com/serverlessworkflow/sdk-go/v3/impl/expr" - "github.com/serverlessworkflow/sdk-go/v3/model" "reflect" "strings" + + "github.com/serverlessworkflow/sdk-go/v3/impl/expr" + "github.com/serverlessworkflow/sdk-go/v3/model" ) const ( diff --git a/impl/task_runner_raise.go b/impl/task_runner_raise.go index 46014a5..b59f01d 100644 --- a/impl/task_runner_raise.go +++ b/impl/task_runner_raise.go @@ -16,6 +16,7 @@ package impl import ( "fmt" + "github.com/serverlessworkflow/sdk-go/v3/model" ) diff --git a/impl/task_runner_raise_test.go b/impl/task_runner_raise_test.go index e85ac28..0c55f3a 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" - "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "testing" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" + "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/stretchr/testify/assert" ) diff --git a/impl/task_runner_set.go b/impl/task_runner_set.go index 295a5f2..fc40e74 100644 --- a/impl/task_runner_set.go +++ b/impl/task_runner_set.go @@ -16,6 +16,7 @@ package impl import ( "fmt" + "github.com/serverlessworkflow/sdk-go/v3/model" ) diff --git a/impl/testdata/switch_match.yaml b/impl/testdata/switch_match.yaml new file mode 100644 index 0000000..4f913af --- /dev/null +++ b/impl/testdata/switch_match.yaml @@ -0,0 +1,43 @@ +# 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 + name: switch-match + version: '1.0.0' +do: + - switchColor: + switch: + - red: + when: '.color == "red"' + then: setRed + - green: + when: '.color == "green"' + then: setGreen + - blue: + when: '.color == "blue"' + then: setBlue + - setRed: + set: + colors: '${ .colors + [ "red" ] }' + then: end + - setGreen: + set: + colors: '${ .colors + [ "green" ] }' + then: end + - setBlue: + set: + colors: '${ .colors + [ "blue" ] }' + then: end diff --git a/impl/testdata/switch_with_default.yaml b/impl/testdata/switch_with_default.yaml new file mode 100644 index 0000000..8a4f1b9 --- /dev/null +++ b/impl/testdata/switch_with_default.yaml @@ -0,0 +1,43 @@ +# 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 + name: switch-with-default + version: '1.0.0' + +do: + - switchColor: + switch: + - red: + when: '.color == "red"' + then: setRed + - green: + when: '.color == "green"' + then: setGreen + - fallback: + then: setDefault + - setRed: + set: + colors: '${ .colors + [ "red" ] }' + then: end + - setGreen: + set: + colors: '${ .colors + [ "green" ] }' + then: end + - setDefault: + set: + colors: '${ .colors + [ "default" ] }' + then: end diff --git a/impl/utils.go b/impl/utils.go index 20b2360..a62559d 100644 --- a/impl/utils.go +++ b/impl/utils.go @@ -16,6 +16,7 @@ package impl import ( "context" + "github.com/serverlessworkflow/sdk-go/v3/impl/expr" "github.com/serverlessworkflow/sdk-go/v3/model" ) diff --git a/model/runtime_expression.go b/model/runtime_expression.go index ae04e46..adef566 100644 --- a/model/runtime_expression.go +++ b/model/runtime_expression.go @@ -17,8 +17,9 @@ package model import ( "encoding/json" "fmt" - "github.com/itchyny/gojq" "strings" + + "github.com/itchyny/gojq" ) // RuntimeExpression represents a runtime expression. @@ -59,6 +60,14 @@ func IsValidExpr(expression string) bool { return err == nil } +// NormalizeExpr adds ${} to the given string +func NormalizeExpr(expr string) string { + if strings.HasPrefix(expr, "${") { + return expr + } + return fmt.Sprintf("${%s}", expr) +} + // IsValid checks if the RuntimeExpression value is valid, handling both with and without `${}`. func (r *RuntimeExpression) IsValid() bool { return IsValidExpr(r.Value) From f72901259dd88dcc36baad5e1a9baa86ae84d783 Mon Sep 17 00:00:00 2001 From: Francisco Javier Tirado Sarti <65240126+fjtirado@users.noreply.github.com> Date: Mon, 7 Apr 2025 23:43:47 +0200 Subject: [PATCH 05/11] Refactoring run method (#236) * Refactoring run method Signed-off-by: fjtirado * Zaninis comments Signed-off-by: fjtirado --------- Signed-off-by: fjtirado --- impl/runner.go | 4 +- impl/task_runner.go | 2 +- impl/task_runner_call_http.go | 44 +++++++++++++++ impl/task_runner_do.go | 100 ++++++++++++++++----------------- impl/task_runner_for.go | 36 ++++++------ impl/task_runner_raise.go | 22 ++++---- impl/task_runner_raise_test.go | 12 ++-- impl/task_runner_set.go | 16 +++--- impl/task_set_test.go | 52 ++++++++--------- 9 files changed, 164 insertions(+), 124 deletions(-) create mode 100644 impl/task_runner_call_http.go diff --git a/impl/runner.go b/impl/runner.go index 5328ee3..362db1b 100644 --- a/impl/runner.go +++ b/impl/runner.go @@ -137,12 +137,12 @@ func (wr *workflowRunnerImpl) Run(input interface{}) (output interface{}, err er wr.RunnerCtx.SetInput(input) // Run tasks sequentially wr.RunnerCtx.SetStatus(ctx.RunningStatus) - doRunner, err := NewDoTaskRunner(wr.Workflow.Do, wr) + doRunner, err := NewDoTaskRunner(wr.Workflow.Do) if err != nil { return nil, err } wr.RunnerCtx.SetStartedAt(time.Now()) - output, err = doRunner.Run(wr.RunnerCtx.GetInput()) + output, err = doRunner.Run(wr.RunnerCtx.GetInput(), wr) if err != nil { return nil, err } diff --git a/impl/task_runner.go b/impl/task_runner.go index 6d9069d..ea7b6dd 100644 --- a/impl/task_runner.go +++ b/impl/task_runner.go @@ -28,7 +28,7 @@ var _ TaskRunner = &ForTaskRunner{} var _ TaskRunner = &DoTaskRunner{} type TaskRunner interface { - Run(input interface{}) (interface{}, error) + Run(input interface{}, taskSupport TaskSupport) (interface{}, error) GetTaskName() string } diff --git a/impl/task_runner_call_http.go b/impl/task_runner_call_http.go new file mode 100644 index 0000000..3093506 --- /dev/null +++ b/impl/task_runner_call_http.go @@ -0,0 +1,44 @@ +// 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 ( + "fmt" + + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +type CallHTTPTaskRunner struct { + TaskName string +} + +func NewCallHttpRunner(taskName string, task *model.CallHTTP) (taskRunner *CallHTTPTaskRunner, err error) { + if task == nil { + err = model.NewErrValidation(fmt.Errorf("invalid For task %s", taskName), taskName) + } else { + taskRunner = new(CallHTTPTaskRunner) + taskRunner.TaskName = taskName + } + return +} + +func (f *CallHTTPTaskRunner) Run(input interface{}, taskSupport TaskSupport) (interface{}, error) { + return input, nil + +} + +func (f *CallHTTPTaskRunner) GetTaskName() string { + return f.TaskName +} diff --git a/impl/task_runner_do.go b/impl/task_runner_do.go index 81ef374..0301009 100644 --- a/impl/task_runner_do.go +++ b/impl/task_runner_do.go @@ -23,38 +23,38 @@ import ( ) // NewTaskRunner creates a TaskRunner instance based on the task type. -func NewTaskRunner(taskName string, task model.Task, taskSupport TaskSupport) (TaskRunner, error) { +func NewTaskRunner(taskName string, task model.Task, workflowDef *model.Workflow) (TaskRunner, error) { switch t := task.(type) { case *model.SetTask: - return NewSetTaskRunner(taskName, t, taskSupport) + return NewSetTaskRunner(taskName, t) case *model.RaiseTask: - return NewRaiseTaskRunner(taskName, t, taskSupport) + return NewRaiseTaskRunner(taskName, t, workflowDef) case *model.DoTask: - return NewDoTaskRunner(t.Do, taskSupport) + return NewDoTaskRunner(t.Do) case *model.ForTask: - return NewForTaskRunner(taskName, t, taskSupport) + return NewForTaskRunner(taskName, t) + case *model.CallHTTP: + return NewCallHttpRunner(taskName, t) default: return nil, fmt.Errorf("unsupported task type '%T' for task '%s'", t, taskName) } } -func NewDoTaskRunner(taskList *model.TaskList, taskSupport TaskSupport) (*DoTaskRunner, error) { +func NewDoTaskRunner(taskList *model.TaskList) (*DoTaskRunner, error) { return &DoTaskRunner{ - TaskList: taskList, - TaskSupport: taskSupport, + TaskList: taskList, }, nil } type DoTaskRunner struct { - TaskList *model.TaskList - TaskSupport TaskSupport + TaskList *model.TaskList } -func (d *DoTaskRunner) Run(input interface{}) (output interface{}, err error) { +func (d *DoTaskRunner) Run(input interface{}, taskSupport TaskSupport) (output interface{}, err error) { if d.TaskList == nil { return input, nil } - return d.runTasks(input, d.TaskList) + return d.runTasks(input, taskSupport) } func (d *DoTaskRunner) GetTaskName() string { @@ -62,71 +62,71 @@ func (d *DoTaskRunner) GetTaskName() string { } // runTasks runs all defined tasks sequentially. -func (d *DoTaskRunner) runTasks(input interface{}, tasks *model.TaskList) (output interface{}, err error) { +func (d *DoTaskRunner) runTasks(input interface{}, taskSupport TaskSupport) (output interface{}, err error) { output = input - if tasks == nil { + if d.TaskList == nil { return output, nil } idx := 0 - currentTask := (*tasks)[idx] + currentTask := (*d.TaskList)[idx] for currentTask != nil { - if err = d.TaskSupport.SetTaskDef(currentTask); err != nil { + if err = taskSupport.SetTaskDef(currentTask); err != nil { return nil, err } - if err = d.TaskSupport.SetTaskReferenceFromName(currentTask.Key); err != nil { + if err = taskSupport.SetTaskReferenceFromName(currentTask.Key); err != nil { return nil, err } - if shouldRun, err := d.shouldRunTask(input, currentTask); err != nil { + if shouldRun, err := d.shouldRunTask(input, taskSupport, currentTask); err != nil { return output, err } else if !shouldRun { - idx, currentTask = tasks.Next(idx) + idx, currentTask = d.TaskList.Next(idx) continue } - d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.PendingStatus) + taskSupport.SetTaskStatus(currentTask.Key, ctx.PendingStatus) // Check if this task is a SwitchTask and handle it if switchTask, ok := currentTask.Task.(*model.SwitchTask); ok { - flowDirective, err := d.evaluateSwitchTask(input, currentTask.Key, switchTask) + flowDirective, err := d.evaluateSwitchTask(input, taskSupport, currentTask.Key, switchTask) if err != nil { - d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.FaultedStatus) + taskSupport.SetTaskStatus(currentTask.Key, ctx.FaultedStatus) return output, err } - d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.CompletedStatus) + taskSupport.SetTaskStatus(currentTask.Key, ctx.CompletedStatus) // Process FlowDirective: update idx/currentTask accordingly - idx, currentTask = tasks.KeyAndIndex(flowDirective.Value) + idx, currentTask = d.TaskList.KeyAndIndex(flowDirective.Value) if currentTask == nil { return nil, fmt.Errorf("flow directive target '%s' not found", flowDirective.Value) } continue } - runner, err := NewTaskRunner(currentTask.Key, currentTask.Task, d.TaskSupport) + runner, err := NewTaskRunner(currentTask.Key, currentTask.Task, taskSupport.GetWorkflowDef()) if err != nil { return output, err } - d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.RunningStatus) - if output, err = d.runTask(input, runner, currentTask.Task.GetBase()); err != nil { - d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.FaultedStatus) + taskSupport.SetTaskStatus(currentTask.Key, ctx.RunningStatus) + if output, err = d.runTask(input, taskSupport, runner, currentTask.Task.GetBase()); err != nil { + taskSupport.SetTaskStatus(currentTask.Key, ctx.FaultedStatus) return output, err } - d.TaskSupport.SetTaskStatus(currentTask.Key, ctx.CompletedStatus) + taskSupport.SetTaskStatus(currentTask.Key, ctx.CompletedStatus) input = deepCloneValue(output) - idx, currentTask = tasks.Next(idx) + idx, currentTask = d.TaskList.Next(idx) } return output, nil } -func (d *DoTaskRunner) shouldRunTask(input interface{}, task *model.TaskItem) (bool, error) { +func (d *DoTaskRunner) shouldRunTask(input interface{}, taskSupport TaskSupport, task *model.TaskItem) (bool, error) { if task.GetBase().If != nil { - output, err := traverseAndEvaluateBool(task.GetBase().If.String(), input, d.TaskSupport.GetContext()) + output, err := traverseAndEvaluateBool(task.GetBase().If.String(), input, taskSupport.GetContext()) if err != nil { return false, model.NewErrExpression(err, task.Key) } @@ -135,7 +135,7 @@ func (d *DoTaskRunner) shouldRunTask(input interface{}, task *model.TaskItem) (b return true, nil } -func (d *DoTaskRunner) evaluateSwitchTask(input interface{}, taskKey string, switchTask *model.SwitchTask) (*model.FlowDirective, error) { +func (d *DoTaskRunner) evaluateSwitchTask(input interface{}, taskSupport TaskSupport, taskKey string, switchTask *model.SwitchTask) (*model.FlowDirective, error) { var defaultThen *model.FlowDirective for _, switchItem := range switchTask.Switch { for _, switchCase := range switchItem { @@ -143,7 +143,7 @@ func (d *DoTaskRunner) evaluateSwitchTask(input interface{}, taskKey string, swi defaultThen = switchCase.Then continue } - result, err := traverseAndEvaluateBool(model.NormalizeExpr(switchCase.When.String()), input, d.TaskSupport.GetContext()) + result, err := traverseAndEvaluateBool(model.NormalizeExpr(switchCase.When.String()), input, taskSupport.GetContext()) if err != nil { return nil, model.NewErrExpression(err, taskKey) } @@ -162,31 +162,31 @@ func (d *DoTaskRunner) evaluateSwitchTask(input interface{}, taskKey string, swi } // runTask executes an individual task. -func (d *DoTaskRunner) runTask(input interface{}, runner TaskRunner, task *model.TaskBase) (output interface{}, err error) { +func (d *DoTaskRunner) runTask(input interface{}, taskSupport TaskSupport, runner TaskRunner, task *model.TaskBase) (output interface{}, err error) { taskName := runner.GetTaskName() - d.TaskSupport.SetTaskStartedAt(time.Now()) - d.TaskSupport.SetTaskRawInput(input) - d.TaskSupport.SetTaskName(taskName) + taskSupport.SetTaskStartedAt(time.Now()) + taskSupport.SetTaskRawInput(input) + taskSupport.SetTaskName(taskName) if task.Input != nil { - if input, err = d.processTaskInput(task, input, taskName); err != nil { + if input, err = d.processTaskInput(task, input, taskSupport, taskName); err != nil { return nil, err } } - output, err = runner.Run(input) + output, err = runner.Run(input, taskSupport) if err != nil { return nil, err } - d.TaskSupport.SetTaskRawOutput(output) + taskSupport.SetTaskRawOutput(output) - if output, err = d.processTaskOutput(task, output, taskName); err != nil { + if output, err = d.processTaskOutput(task, output, taskSupport, taskName); err != nil { return nil, err } - if err = d.processTaskExport(task, output, taskName); err != nil { + if err = d.processTaskExport(task, output, taskSupport, taskName); err != nil { return nil, err } @@ -194,7 +194,7 @@ func (d *DoTaskRunner) runTask(input interface{}, runner TaskRunner, task *model } // processTaskInput processes task input validation and transformation. -func (d *DoTaskRunner) processTaskInput(task *model.TaskBase, taskInput interface{}, taskName string) (output interface{}, err error) { +func (d *DoTaskRunner) processTaskInput(task *model.TaskBase, taskInput interface{}, taskSupport TaskSupport, taskName string) (output interface{}, err error) { if task.Input == nil { return taskInput, nil } @@ -203,7 +203,7 @@ func (d *DoTaskRunner) processTaskInput(task *model.TaskBase, taskInput interfac return nil, err } - if output, err = traverseAndEvaluate(task.Input.From, taskInput, taskName, d.TaskSupport.GetContext()); err != nil { + if output, err = traverseAndEvaluate(task.Input.From, taskInput, taskName, taskSupport.GetContext()); err != nil { return nil, err } @@ -211,12 +211,12 @@ func (d *DoTaskRunner) processTaskInput(task *model.TaskBase, taskInput interfac } // processTaskOutput processes task output validation and transformation. -func (d *DoTaskRunner) processTaskOutput(task *model.TaskBase, taskOutput interface{}, taskName string) (output interface{}, err error) { +func (d *DoTaskRunner) processTaskOutput(task *model.TaskBase, taskOutput interface{}, taskSupport TaskSupport, taskName string) (output interface{}, err error) { if task.Output == nil { return taskOutput, nil } - if output, err = traverseAndEvaluate(task.Output.As, taskOutput, taskName, d.TaskSupport.GetContext()); err != nil { + if output, err = traverseAndEvaluate(task.Output.As, taskOutput, taskName, taskSupport.GetContext()); err != nil { return nil, err } @@ -227,12 +227,12 @@ func (d *DoTaskRunner) processTaskOutput(task *model.TaskBase, taskOutput interf return output, nil } -func (d *DoTaskRunner) processTaskExport(task *model.TaskBase, taskOutput interface{}, taskName string) (err error) { +func (d *DoTaskRunner) processTaskExport(task *model.TaskBase, taskOutput interface{}, taskSupport TaskSupport, taskName string) (err error) { if task.Export == nil { return nil } - output, err := traverseAndEvaluate(task.Export.As, taskOutput, taskName, d.TaskSupport.GetContext()) + output, err := traverseAndEvaluate(task.Export.As, taskOutput, taskName, taskSupport.GetContext()) if err != nil { return err } @@ -241,7 +241,7 @@ func (d *DoTaskRunner) processTaskExport(task *model.TaskBase, taskOutput interf return nil } - d.TaskSupport.SetWorkflowInstanceCtx(output) + taskSupport.SetWorkflowInstanceCtx(output) return nil } diff --git a/impl/task_runner_for.go b/impl/task_runner_for.go index fb7bcff..a53348d 100644 --- a/impl/task_runner_for.go +++ b/impl/task_runner_for.go @@ -28,38 +28,36 @@ const ( forTaskDefaultAt = "$index" ) -func NewForTaskRunner(taskName string, task *model.ForTask, taskSupport TaskSupport) (*ForTaskRunner, error) { +func NewForTaskRunner(taskName string, task *model.ForTask) (*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) + doRunner, err := NewDoTaskRunner(task.Do) if err != nil { return nil, err } return &ForTaskRunner{ - Task: task, - TaskName: taskName, - DoRunner: doRunner, - TaskSupport: taskSupport, + Task: task, + TaskName: taskName, + DoRunner: doRunner, }, nil } type ForTaskRunner struct { - Task *model.ForTask - TaskName string - DoRunner *DoTaskRunner - TaskSupport TaskSupport + Task *model.ForTask + TaskName string + DoRunner *DoTaskRunner } -func (f *ForTaskRunner) Run(input interface{}) (interface{}, error) { +func (f *ForTaskRunner) Run(input interface{}, taskSupport TaskSupport) (interface{}, error) { defer func() { // clear local variables - f.TaskSupport.RemoveLocalExprVars(f.Task.For.Each, f.Task.For.At) + taskSupport.RemoveLocalExprVars(f.Task.For.Each, f.Task.For.At) }() f.sanitizeFor() - in, err := expr.TraverseAndEvaluate(f.Task.For.In, input, f.TaskSupport.GetContext()) + in, err := expr.TraverseAndEvaluate(f.Task.For.In, input, taskSupport.GetContext()) if err != nil { return nil, err } @@ -71,11 +69,11 @@ func (f *ForTaskRunner) Run(input interface{}) (interface{}, error) { for i := 0; i < rv.Len(); i++ { item := rv.Index(i).Interface() - if forOutput, err = f.processForItem(i, item, forOutput); err != nil { + if forOutput, err = f.processForItem(i, item, taskSupport, forOutput); err != nil { return nil, err } if f.Task.While != "" { - whileIsTrue, err := traverseAndEvaluateBool(f.Task.While, forOutput, f.TaskSupport.GetContext()) + whileIsTrue, err := traverseAndEvaluateBool(f.Task.While, forOutput, taskSupport.GetContext()) if err != nil { return nil, err } @@ -87,7 +85,7 @@ func (f *ForTaskRunner) Run(input interface{}) (interface{}, error) { case reflect.Invalid: return input, nil default: - if forOutput, err = f.processForItem(0, in, forOutput); err != nil { + if forOutput, err = f.processForItem(0, in, taskSupport, forOutput); err != nil { return nil, err } } @@ -95,16 +93,16 @@ func (f *ForTaskRunner) Run(input interface{}) (interface{}, error) { return forOutput, nil } -func (f *ForTaskRunner) processForItem(idx int, item interface{}, forOutput interface{}) (interface{}, error) { +func (f *ForTaskRunner) processForItem(idx int, item interface{}, taskSupport TaskSupport, forOutput interface{}) (interface{}, error) { forVars := map[string]interface{}{ f.Task.For.At: idx, f.Task.For.Each: item, } // Instead of Set, we Add since other tasks in this very same context might be adding variables to the context - f.TaskSupport.AddLocalExprVars(forVars) + taskSupport.AddLocalExprVars(forVars) // output from previous iterations are merged together var err error - forOutput, err = f.DoRunner.Run(forOutput) + forOutput, err = f.DoRunner.Run(forOutput, taskSupport) if err != nil { return nil, err } diff --git a/impl/task_runner_raise.go b/impl/task_runner_raise.go index b59f01d..0de588f 100644 --- a/impl/task_runner_raise.go +++ b/impl/task_runner_raise.go @@ -20,8 +20,8 @@ import ( "github.com/serverlessworkflow/sdk-go/v3/model" ) -func NewRaiseTaskRunner(taskName string, task *model.RaiseTask, taskSupport TaskSupport) (*RaiseTaskRunner, error) { - if err := resolveErrorDefinition(task, taskSupport.GetWorkflowDef()); err != nil { +func NewRaiseTaskRunner(taskName string, task *model.RaiseTask, workflowDef *model.Workflow) (*RaiseTaskRunner, error) { + if err := resolveErrorDefinition(task, workflowDef); err != nil { return nil, err } @@ -29,9 +29,8 @@ func NewRaiseTaskRunner(taskName string, task *model.RaiseTask, taskSupport Task return nil, model.NewErrValidation(fmt.Errorf("no raise configuration provided for RaiseTask %s", taskName), taskName) } return &RaiseTaskRunner{ - Task: task, - TaskName: taskName, - TaskSupport: taskSupport, + Task: task, + TaskName: taskName, }, nil } @@ -53,9 +52,8 @@ func resolveErrorDefinition(t *model.RaiseTask, workflowDef *model.Workflow) err } type RaiseTaskRunner struct { - Task *model.RaiseTask - TaskName string - TaskSupport TaskSupport + Task *model.RaiseTask + TaskName string } var raiseErrFuncMapping = map[string]func(error, string) *model.Error{ @@ -69,22 +67,22 @@ var raiseErrFuncMapping = map[string]func(error, string) *model.Error{ model.ErrorTypeTimeout: model.NewErrTimeout, } -func (r *RaiseTaskRunner) Run(input interface{}) (output interface{}, err error) { +func (r *RaiseTaskRunner) Run(input interface{}, taskSupport TaskSupport) (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, r.TaskSupport.GetContext()) + detailResult, err = traverseAndEvaluate(r.Task.Raise.Error.Definition.Detail.AsObjectOrRuntimeExpr(), input, r.TaskName, taskSupport.GetContext()) if err != nil { return nil, err } var titleResult interface{} - titleResult, err = traverseAndEvaluate(r.Task.Raise.Error.Definition.Title.AsObjectOrRuntimeExpr(), input, r.TaskName, r.TaskSupport.GetContext()) + titleResult, err = traverseAndEvaluate(r.Task.Raise.Error.Definition.Title.AsObjectOrRuntimeExpr(), input, r.TaskName, taskSupport.GetContext()) if err != nil { return nil, err } - instance := r.TaskSupport.GetTaskReference() + instance := taskSupport.GetTaskReference() var raiseErr *model.Error if raiseErrF, ok := raiseErrFuncMapping[r.Task.Raise.Error.Definition.Type.String()]; ok { diff --git a/impl/task_runner_raise_test.go b/impl/task_runner_raise_test.go index 0c55f3a..3de0aae 100644 --- a/impl/task_runner_raise_test.go +++ b/impl/task_runner_raise_test.go @@ -45,10 +45,11 @@ func TestRaiseTaskRunner_WithDefinedError(t *testing.T) { assert.NoError(t, err) wfCtx.SetTaskReference("task_raise_defined") - runner, err := NewRaiseTaskRunner("task_raise_defined", raiseTask, newTaskSupport(withRunnerCtx(wfCtx))) + taskSupport := newTaskSupport(withRunnerCtx(wfCtx)) + runner, err := NewRaiseTaskRunner("task_raise_defined", raiseTask, taskSupport.GetWorkflowDef()) assert.NoError(t, err) - output, err := runner.Run(input) + output, err := runner.Run(input, taskSupport) assert.Equal(t, output, input) assert.Error(t, err) @@ -76,7 +77,7 @@ func TestRaiseTaskRunner_WithReferencedError(t *testing.T) { }, } - runner, err := NewRaiseTaskRunner("task_raise_ref", raiseTask, newTaskSupport()) + runner, err := NewRaiseTaskRunner("task_raise_ref", raiseTask, &model.Workflow{}) assert.Error(t, err) assert.Nil(t, runner) } @@ -103,10 +104,11 @@ func TestRaiseTaskRunner_TimeoutErrorWithExpression(t *testing.T) { assert.NoError(t, err) wfCtx.SetTaskReference("task_raise_timeout_expr") - runner, err := NewRaiseTaskRunner("task_raise_timeout_expr", raiseTask, newTaskSupport(withRunnerCtx(wfCtx))) + taskSupport := newTaskSupport(withRunnerCtx(wfCtx)) + runner, err := NewRaiseTaskRunner("task_raise_timeout_expr", raiseTask, taskSupport.GetWorkflowDef()) assert.NoError(t, err) - output, err := runner.Run(input) + output, err := runner.Run(input, taskSupport) assert.Equal(t, input, output) assert.Error(t, err) diff --git a/impl/task_runner_set.go b/impl/task_runner_set.go index fc40e74..40ff185 100644 --- a/impl/task_runner_set.go +++ b/impl/task_runner_set.go @@ -20,30 +20,28 @@ import ( "github.com/serverlessworkflow/sdk-go/v3/model" ) -func NewSetTaskRunner(taskName string, task *model.SetTask, taskSupport TaskSupport) (*SetTaskRunner, error) { +func NewSetTaskRunner(taskName string, task *model.SetTask) (*SetTaskRunner, error) { if task == nil || task.Set == nil { return nil, model.NewErrValidation(fmt.Errorf("no set configuration provided for SetTask %s", taskName), taskName) } return &SetTaskRunner{ - Task: task, - TaskName: taskName, - TaskSupport: taskSupport, + Task: task, + TaskName: taskName, }, nil } type SetTaskRunner struct { - Task *model.SetTask - TaskName string - TaskSupport TaskSupport + Task *model.SetTask + TaskName string } func (s *SetTaskRunner) GetTaskName() string { return s.TaskName } -func (s *SetTaskRunner) Run(input interface{}) (output interface{}, err error) { +func (s *SetTaskRunner) Run(input interface{}, taskSupport TaskSupport) (output interface{}, err error) { setObject := deepClone(s.Task.Set) - result, err := traverseAndEvaluate(model.NewObjectOrRuntimeExpr(setObject), input, s.TaskName, s.TaskSupport.GetContext()) + result, err := traverseAndEvaluate(model.NewObjectOrRuntimeExpr(setObject), input, s.TaskName, taskSupport.GetContext()) if err != nil { return nil, err } diff --git a/impl/task_set_test.go b/impl/task_set_test.go index c1d5534..c02d76d 100644 --- a/impl/task_set_test.go +++ b/impl/task_set_test.go @@ -45,10 +45,10 @@ func TestSetTaskExecutor_Exec(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task1", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task1", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -79,10 +79,10 @@ func TestSetTaskExecutor_StaticValues(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_static", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_static", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -109,10 +109,10 @@ func TestSetTaskExecutor_RuntimeExpressions(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_runtime_expr", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_runtime_expr", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -141,10 +141,10 @@ func TestSetTaskExecutor_NestedStructures(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_nested_structures", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_nested_structures", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -176,10 +176,10 @@ func TestSetTaskExecutor_StaticAndDynamicValues(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_static_dynamic", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_static_dynamic", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -201,10 +201,10 @@ func TestSetTaskExecutor_MissingInputData(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_missing_input", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_missing_input", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) assert.Nil(t, output.(map[string]interface{})["value"]) } @@ -220,10 +220,10 @@ func TestSetTaskExecutor_ExpressionsWithFunctions(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_expr_functions", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_expr_functions", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -246,10 +246,10 @@ func TestSetTaskExecutor_ConditionalExpressions(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_conditional_expr", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_conditional_expr", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -273,10 +273,10 @@ func TestSetTaskExecutor_ArrayDynamicIndex(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_array_indexing", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_array_indexing", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -299,10 +299,10 @@ func TestSetTaskExecutor_NestedConditionalLogic(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_nested_condition", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_nested_condition", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -323,10 +323,10 @@ func TestSetTaskExecutor_DefaultValues(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_default_values", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_default_values", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -363,10 +363,10 @@ func TestSetTaskExecutor_ComplexNestedStructures(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_complex_nested", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_complex_nested", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ @@ -399,10 +399,10 @@ func TestSetTaskExecutor_MultipleExpressions(t *testing.T) { }, } - executor, err := NewSetTaskRunner("task_multiple_expr", setTask, newTaskSupport()) + executor, err := NewSetTaskRunner("task_multiple_expr", setTask) assert.NoError(t, err) - output, err := executor.Run(input) + output, err := executor.Run(input, newTaskSupport()) assert.NoError(t, err) expectedOutput := map[string]interface{}{ From 46481f69f82207a56b6dbe910fb39179d9b4ff38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Apr 2025 09:32:28 -0400 Subject: [PATCH 06/11] chore(deps): bump golang.org/x/net from 0.37.0 to 0.38.0 (#237) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.37.0 to 0.38.0. - [Commits](https://github.com/golang/net/compare/v0.37.0...v0.38.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.38.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 5 ++--- go.sum | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index e7947a8..646715d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.0 require ( github.com/go-playground/validator/v10 v10.25.0 + github.com/google/uuid v1.6.0 github.com/itchyny/gojq v0.12.17 github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 @@ -19,17 +20,15 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/relvacode/iso8601 v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.37.0 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index e6e3d38..489a35c 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,6 @@ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjS 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/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= -github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= 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= @@ -47,8 +45,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= From 23710eeb237df59f27d870208109867589491dbb Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Wed, 30 Apr 2025 11:56:39 -0300 Subject: [PATCH 07/11] Fix #238 - Add support to fork task (#240) * Fix #238 - Add support to fork task Signed-off-by: Ricardo Zanini * Adding missed headers Signed-off-by: Ricardo Zanini * Fix linters, makefile, fmt Signed-off-by: Ricardo Zanini * Fix Labeler CI Signed-off-by: Ricardo Zanini * Remove labeler Signed-off-by: Ricardo Zanini --------- Signed-off-by: Ricardo Zanini --- .github/workflows/pull_request_labeler.yml | 26 ---- Makefile | 8 +- impl/ctx/context.go | 35 +++++ impl/expr/expr.go | 25 ++++ impl/runner.go | 23 +++- impl/runner_test.go | 11 ++ impl/task_runner.go | 3 + impl/task_runner_do.go | 23 ++-- impl/task_runner_for.go | 2 +- impl/task_runner_fork.go | 120 ++++++++++++++++++ impl/task_runner_fork_test.go | 101 +++++++++++++++ impl/task_runner_raise.go | 5 +- impl/task_runner_set.go | 7 +- ...sk_set_test.go => task_runner_set_test.go} | 0 .../testdata/fork_simple.yaml | 29 +++-- impl/utils.go | 79 ------------ impl/{ => utils}/json_schema.go | 15 ++- impl/utils/utils.go | 38 ++++++ 18 files changed, 413 insertions(+), 137 deletions(-) delete mode 100644 .github/workflows/pull_request_labeler.yml create mode 100644 impl/task_runner_fork.go create mode 100644 impl/task_runner_fork_test.go rename impl/{task_set_test.go => task_runner_set_test.go} (100%) rename .github/labeler.yml => impl/testdata/fork_simple.yaml (51%) delete mode 100644 impl/utils.go rename impl/{ => utils}/json_schema.go (84%) create mode 100644 impl/utils/utils.go diff --git a/.github/workflows/pull_request_labeler.yml b/.github/workflows/pull_request_labeler.yml deleted file mode 100644 index f270294..0000000 --- a/.github/workflows/pull_request_labeler.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright 2022 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. - -name: "Pull Request Labeler" -on: - - pull_request_target - -jobs: - labeler: - permissions: - contents: read - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@v5 \ No newline at end of file diff --git a/Makefile b/Makefile index 767d158..34bfc91 100644 --- a/Makefile +++ b/Makefile @@ -11,12 +11,14 @@ goimports: @goimports -w . lint: - @echo "πŸš€ Running lint..." - @command -v golangci-lint > /dev/null || (echo "πŸš€ Installing golangci-lint..."; curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "${GOPATH}/bin") + @echo "πŸš€ Installing/updating golangci-lint…" + GO111MODULE=on go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + + @echo "πŸš€ Running lint…" @make addheaders @make goimports @make fmt - @./hack/go-lint.sh ${params} + @$(GOPATH)/bin/golangci-lint run ./... ${params} @echo "βœ… Linting completed!" .PHONY: test diff --git a/impl/ctx/context.go b/impl/ctx/context.go index f013507..ff1d260 100644 --- a/impl/ctx/context.go +++ b/impl/ctx/context.go @@ -22,6 +22,8 @@ import ( "sync" "time" + "github.com/serverlessworkflow/sdk-go/v3/impl/utils" + "github.com/google/uuid" "github.com/serverlessworkflow/sdk-go/v3/model" ) @@ -71,6 +73,7 @@ type WorkflowContext interface { SetLocalExprVars(vars map[string]interface{}) AddLocalExprVars(vars map[string]interface{}) RemoveLocalExprVars(keys ...string) + Clone() WorkflowContext } // workflowContext holds the necessary data for the workflow execution within the instance. @@ -118,6 +121,38 @@ func GetWorkflowContext(ctx context.Context) (WorkflowContext, error) { return wfCtx, nil } +func (ctx *workflowContext) Clone() WorkflowContext { + ctx.mu.Lock() + defer ctx.mu.Unlock() + + newInput := utils.DeepCloneValue(ctx.input) + newOutput := utils.DeepCloneValue(ctx.output) + + // deep clone each of the maps + newContextMap := utils.DeepClone(ctx.context) + newWorkflowDesc := utils.DeepClone(ctx.workflowDescriptor) + newTaskDesc := utils.DeepClone(ctx.taskDescriptor) + newLocalExprVars := utils.DeepClone(ctx.localExprVars) + + newStatusPhase := append([]StatusPhaseLog(nil), ctx.StatusPhase...) + + newTasksStatusPhase := make(map[string][]StatusPhaseLog, len(ctx.TasksStatusPhase)) + for taskName, logs := range ctx.TasksStatusPhase { + newTasksStatusPhase[taskName] = append([]StatusPhaseLog(nil), logs...) + } + + return &workflowContext{ + input: newInput, + output: newOutput, + context: newContextMap, + workflowDescriptor: newWorkflowDesc, + taskDescriptor: newTaskDesc, + localExprVars: newLocalExprVars, + StatusPhase: newStatusPhase, + TasksStatusPhase: newTasksStatusPhase, + } +} + func (ctx *workflowContext) SetStartedAt(t time.Time) { ctx.mu.Lock() defer ctx.mu.Unlock() diff --git a/impl/expr/expr.go b/impl/expr/expr.go index 60e2765..77faffb 100644 --- a/impl/expr/expr.go +++ b/impl/expr/expr.go @@ -132,3 +132,28 @@ func mergeContextInVars(nodeCtx context.Context, variables map[string]interface{ return nil } + +func TraverseAndEvaluateObj(runtimeExpr *model.ObjectOrRuntimeExpr, input interface{}, taskName string, wfCtx context.Context) (output interface{}, err error) { + if runtimeExpr == nil { + return input, nil + } + output, err = TraverseAndEvaluate(runtimeExpr.AsStringOrMap(), input, wfCtx) + if err != nil { + return nil, model.NewErrExpression(err, taskName) + } + return output, nil +} + +func TraverseAndEvaluateBool(runtimeExpr string, input interface{}, wfCtx context.Context) (bool, error) { + if len(runtimeExpr) == 0 { + return false, nil + } + output, err := TraverseAndEvaluate(runtimeExpr, input, wfCtx) + if err != nil { + return false, nil + } + if result, ok := output.(bool); ok { + return result, nil + } + return false, nil +} diff --git a/impl/runner.go b/impl/runner.go index 362db1b..33d852a 100644 --- a/impl/runner.go +++ b/impl/runner.go @@ -19,6 +19,9 @@ import ( "fmt" "time" + "github.com/serverlessworkflow/sdk-go/v3/impl/expr" + "github.com/serverlessworkflow/sdk-go/v3/impl/utils" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" ) @@ -53,6 +56,18 @@ type workflowRunnerImpl struct { RunnerCtx ctx.WorkflowContext } +func (wr *workflowRunnerImpl) CloneWithContext(newCtx context.Context) TaskSupport { + clonedWfCtx := wr.RunnerCtx.Clone() + + ctxWithWf := ctx.WithWorkflowContext(newCtx, clonedWfCtx) + + return &workflowRunnerImpl{ + Workflow: wr.Workflow, + Context: ctxWithWf, + RunnerCtx: clonedWfCtx, + } +} + func (wr *workflowRunnerImpl) RemoveLocalExprVars(keys ...string) { wr.RunnerCtx.RemoveLocalExprVars(keys...) } @@ -175,13 +190,13 @@ func (wr *workflowRunnerImpl) wrapWorkflowError(err error) error { func (wr *workflowRunnerImpl) processInput(input interface{}) (output interface{}, err error) { if wr.Workflow.Input != nil { if wr.Workflow.Input.Schema != nil { - if err = validateSchema(input, wr.Workflow.Input.Schema, "/"); err != nil { + if err = utils.ValidateSchema(input, wr.Workflow.Input.Schema, "/"); err != nil { return nil, err } } if wr.Workflow.Input.From != nil { - output, err = traverseAndEvaluate(wr.Workflow.Input.From, input, "/", wr.Context) + output, err = expr.TraverseAndEvaluateObj(wr.Workflow.Input.From, input, "/", wr.Context) if err != nil { return nil, err } @@ -196,13 +211,13 @@ func (wr *workflowRunnerImpl) processOutput(output interface{}) (interface{}, er if wr.Workflow.Output != nil { if wr.Workflow.Output.As != nil { var err error - output, err = traverseAndEvaluate(wr.Workflow.Output.As, output, "/", wr.Context) + output, err = expr.TraverseAndEvaluateObj(wr.Workflow.Output.As, output, "/", wr.Context) if err != nil { return nil, err } } if wr.Workflow.Output.Schema != nil { - if err := validateSchema(output, wr.Workflow.Output.Schema, "/"); err != nil { + if err := utils.ValidateSchema(output, wr.Workflow.Output.Schema, "/"); err != nil { return nil, err } } diff --git a/impl/runner_test.go b/impl/runner_test.go index 9bb599c..5acdb6b 100644 --- a/impl/runner_test.go +++ b/impl/runner_test.go @@ -456,3 +456,14 @@ func TestSwitchTaskRunner_DefaultCase(t *testing.T) { runWorkflowTest(t, workflowPath, input, expectedOutput) }) } + +func TestForkSimple_NoCompete(t *testing.T) { + t.Run("Create a color array", func(t *testing.T) { + workflowPath := "./testdata/fork_simple.yaml" + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "colors": []interface{}{"red", "blue"}, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) +} diff --git a/impl/task_runner.go b/impl/task_runner.go index ea7b6dd..f825f79 100644 --- a/impl/task_runner.go +++ b/impl/task_runner.go @@ -53,4 +53,7 @@ type TaskSupport interface { AddLocalExprVars(vars map[string]interface{}) // RemoveLocalExprVars removes local variables added in AddLocalExprVars or SetLocalExprVars RemoveLocalExprVars(keys ...string) + // CloneWithContext returns a full clone of this TaskSupport, but using + // the provided context.Context (so deadlines/cancellations propagate). + CloneWithContext(ctx context.Context) TaskSupport } diff --git a/impl/task_runner_do.go b/impl/task_runner_do.go index 0301009..8b63bfc 100644 --- a/impl/task_runner_do.go +++ b/impl/task_runner_do.go @@ -18,6 +18,9 @@ import ( "fmt" "time" + "github.com/serverlessworkflow/sdk-go/v3/impl/expr" + "github.com/serverlessworkflow/sdk-go/v3/impl/utils" + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" "github.com/serverlessworkflow/sdk-go/v3/model" ) @@ -35,6 +38,8 @@ func NewTaskRunner(taskName string, task model.Task, workflowDef *model.Workflow return NewForTaskRunner(taskName, t) case *model.CallHTTP: return NewCallHttpRunner(taskName, t) + case *model.ForkTask: + return NewForkTaskRunner(taskName, t, workflowDef) default: return nil, fmt.Errorf("unsupported task type '%T' for task '%s'", t, taskName) } @@ -117,7 +122,7 @@ func (d *DoTaskRunner) runTasks(input interface{}, taskSupport TaskSupport) (out } taskSupport.SetTaskStatus(currentTask.Key, ctx.CompletedStatus) - input = deepCloneValue(output) + input = utils.DeepCloneValue(output) idx, currentTask = d.TaskList.Next(idx) } @@ -126,7 +131,7 @@ func (d *DoTaskRunner) runTasks(input interface{}, taskSupport TaskSupport) (out func (d *DoTaskRunner) shouldRunTask(input interface{}, taskSupport TaskSupport, task *model.TaskItem) (bool, error) { if task.GetBase().If != nil { - output, err := traverseAndEvaluateBool(task.GetBase().If.String(), input, taskSupport.GetContext()) + output, err := expr.TraverseAndEvaluateBool(task.GetBase().If.String(), input, taskSupport.GetContext()) if err != nil { return false, model.NewErrExpression(err, task.Key) } @@ -143,7 +148,7 @@ func (d *DoTaskRunner) evaluateSwitchTask(input interface{}, taskSupport TaskSup defaultThen = switchCase.Then continue } - result, err := traverseAndEvaluateBool(model.NormalizeExpr(switchCase.When.String()), input, taskSupport.GetContext()) + result, err := expr.TraverseAndEvaluateBool(model.NormalizeExpr(switchCase.When.String()), input, taskSupport.GetContext()) if err != nil { return nil, model.NewErrExpression(err, taskKey) } @@ -199,11 +204,11 @@ func (d *DoTaskRunner) processTaskInput(task *model.TaskBase, taskInput interfac return taskInput, nil } - if err = validateSchema(taskInput, task.Input.Schema, taskName); err != nil { + if err = utils.ValidateSchema(taskInput, task.Input.Schema, taskName); err != nil { return nil, err } - if output, err = traverseAndEvaluate(task.Input.From, taskInput, taskName, taskSupport.GetContext()); err != nil { + if output, err = expr.TraverseAndEvaluateObj(task.Input.From, taskInput, taskName, taskSupport.GetContext()); err != nil { return nil, err } @@ -216,11 +221,11 @@ func (d *DoTaskRunner) processTaskOutput(task *model.TaskBase, taskOutput interf return taskOutput, nil } - if output, err = traverseAndEvaluate(task.Output.As, taskOutput, taskName, taskSupport.GetContext()); err != nil { + if output, err = expr.TraverseAndEvaluateObj(task.Output.As, taskOutput, taskName, taskSupport.GetContext()); err != nil { return nil, err } - if err = validateSchema(output, task.Output.Schema, taskName); err != nil { + if err = utils.ValidateSchema(output, task.Output.Schema, taskName); err != nil { return nil, err } @@ -232,12 +237,12 @@ func (d *DoTaskRunner) processTaskExport(task *model.TaskBase, taskOutput interf return nil } - output, err := traverseAndEvaluate(task.Export.As, taskOutput, taskName, taskSupport.GetContext()) + output, err := expr.TraverseAndEvaluateObj(task.Export.As, taskOutput, taskName, taskSupport.GetContext()) if err != nil { return err } - if err = validateSchema(output, task.Export.Schema, taskName); err != nil { + if err = utils.ValidateSchema(output, task.Export.Schema, taskName); err != nil { return nil } diff --git a/impl/task_runner_for.go b/impl/task_runner_for.go index a53348d..90461f9 100644 --- a/impl/task_runner_for.go +++ b/impl/task_runner_for.go @@ -73,7 +73,7 @@ func (f *ForTaskRunner) Run(input interface{}, taskSupport TaskSupport) (interfa return nil, err } if f.Task.While != "" { - whileIsTrue, err := traverseAndEvaluateBool(f.Task.While, forOutput, taskSupport.GetContext()) + whileIsTrue, err := expr.TraverseAndEvaluateBool(f.Task.While, forOutput, taskSupport.GetContext()) if err != nil { return nil, err } diff --git a/impl/task_runner_fork.go b/impl/task_runner_fork.go new file mode 100644 index 0000000..9a68399 --- /dev/null +++ b/impl/task_runner_fork.go @@ -0,0 +1,120 @@ +// 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" + "sync" + + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +func NewForkTaskRunner(taskName string, task *model.ForkTask, workflowDef *model.Workflow) (*ForkTaskRunner, error) { + if task == nil || task.Fork.Branches == nil { + return nil, model.NewErrValidation(fmt.Errorf("invalid Fork task %s", taskName), taskName) + } + + var runners []TaskRunner + for _, branchItem := range *task.Fork.Branches { + r, err := NewTaskRunner(branchItem.Key, branchItem.Task, workflowDef) + if err != nil { + return nil, err + } + runners = append(runners, r) + } + + return &ForkTaskRunner{ + Task: task, + TaskName: taskName, + BranchRunners: runners, + }, nil +} + +type ForkTaskRunner struct { + Task *model.ForkTask + TaskName string + BranchRunners []TaskRunner +} + +func (f ForkTaskRunner) GetTaskName() string { + return f.TaskName +} + +func (f ForkTaskRunner) Run(input interface{}, parentSupport TaskSupport) (interface{}, error) { + cancelCtx, cancel := context.WithCancel(parentSupport.GetContext()) + defer cancel() + + n := len(f.BranchRunners) + results := make([]interface{}, n) + errs := make(chan error, n) + done := make(chan struct{}) + resultCh := make(chan interface{}, 1) + + var ( + wg sync.WaitGroup + once sync.Once // <-- declare a Once + ) + + for i, runner := range f.BranchRunners { + wg.Add(1) + go func(i int, runner TaskRunner) { + defer wg.Done() + // **Isolate context** for each branch! + branchSupport := parentSupport.CloneWithContext(cancelCtx) + + select { + case <-cancelCtx.Done(): + return + default: + } + + out, err := runner.Run(input, branchSupport) + if err != nil { + errs <- err + return + } + results[i] = out + + if f.Task.Fork.Compete { + select { + case resultCh <- out: + once.Do(func() { + cancel() // **signal cancellation** to all other branches + close(done) // signal we have a winner + }) + default: + } + } + }(i, runner) + } + + if f.Task.Fork.Compete { + select { + case <-done: + return <-resultCh, nil + case err := <-errs: + return nil, err + } + } + + wg.Wait() + select { + case err := <-errs: + return nil, err + default: + } + return results, nil +} diff --git a/impl/task_runner_fork_test.go b/impl/task_runner_fork_test.go new file mode 100644 index 0000000..f38b817 --- /dev/null +++ b/impl/task_runner_fork_test.go @@ -0,0 +1,101 @@ +// 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" + "testing" + "time" + + "github.com/serverlessworkflow/sdk-go/v3/model" + "github.com/stretchr/testify/assert" +) + +// dummyRunner simulates a TaskRunner that returns its name after an optional delay. +type dummyRunner struct { + name string + delay time.Duration +} + +func (d *dummyRunner) GetTaskName() string { + return d.name +} + +func (d *dummyRunner) Run(input interface{}, ts TaskSupport) (interface{}, error) { + select { + case <-ts.GetContext().Done(): + // canceled + return nil, ts.GetContext().Err() + case <-time.After(d.delay): + // complete after delay + return d.name, nil + } +} + +func TestForkTaskRunner_NonCompete(t *testing.T) { + // Prepare a TaskSupport with a background context + ts := newTaskSupport(withContext(context.Background())) + + // Two branches that complete immediately + branches := []TaskRunner{ + &dummyRunner{name: "r1", delay: 0}, + &dummyRunner{name: "r2", delay: 0}, + } + fork := ForkTaskRunner{ + Task: &model.ForkTask{ + Fork: model.ForkTaskConfiguration{ + Compete: false, + }, + }, + TaskName: "fork", + BranchRunners: branches, + } + + output, err := fork.Run("in", ts) + assert.NoError(t, err) + + results, ok := output.([]interface{}) + assert.True(t, ok, "expected output to be []interface{}") + assert.Equal(t, []interface{}{"r1", "r2"}, results) +} + +func TestForkTaskRunner_Compete(t *testing.T) { + // Prepare a TaskSupport with a background context + ts := newTaskSupport(withContext(context.Background())) + + // One fast branch and one slow branch + branches := []TaskRunner{ + &dummyRunner{name: "fast", delay: 10 * time.Millisecond}, + &dummyRunner{name: "slow", delay: 50 * time.Millisecond}, + } + fork := ForkTaskRunner{ + Task: &model.ForkTask{ + Fork: model.ForkTaskConfiguration{ + Compete: true, + }, + }, + TaskName: "fork", + BranchRunners: branches, + } + + start := time.Now() + output, err := fork.Run("in", ts) + elapsed := time.Since(start) + + assert.NoError(t, err) + assert.Equal(t, "fast", output) + // ensure compete returns before the slow branch would finish + assert.Less(t, elapsed, 50*time.Millisecond, "compete should cancel the slow branch") +} diff --git a/impl/task_runner_raise.go b/impl/task_runner_raise.go index 0de588f..dddaf0c 100644 --- a/impl/task_runner_raise.go +++ b/impl/task_runner_raise.go @@ -17,6 +17,7 @@ package impl import ( "fmt" + "github.com/serverlessworkflow/sdk-go/v3/impl/expr" "github.com/serverlessworkflow/sdk-go/v3/model" ) @@ -71,13 +72,13 @@ func (r *RaiseTaskRunner) Run(input interface{}, taskSupport TaskSupport) (outpu 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, taskSupport.GetContext()) + detailResult, err = expr.TraverseAndEvaluateObj(r.Task.Raise.Error.Definition.Detail.AsObjectOrRuntimeExpr(), input, r.TaskName, taskSupport.GetContext()) if err != nil { return nil, err } var titleResult interface{} - titleResult, err = traverseAndEvaluate(r.Task.Raise.Error.Definition.Title.AsObjectOrRuntimeExpr(), input, r.TaskName, taskSupport.GetContext()) + titleResult, err = expr.TraverseAndEvaluateObj(r.Task.Raise.Error.Definition.Title.AsObjectOrRuntimeExpr(), input, r.TaskName, taskSupport.GetContext()) if err != nil { return nil, err } diff --git a/impl/task_runner_set.go b/impl/task_runner_set.go index 40ff185..f2aaaa9 100644 --- a/impl/task_runner_set.go +++ b/impl/task_runner_set.go @@ -17,6 +17,9 @@ package impl import ( "fmt" + "github.com/serverlessworkflow/sdk-go/v3/impl/expr" + "github.com/serverlessworkflow/sdk-go/v3/impl/utils" + "github.com/serverlessworkflow/sdk-go/v3/model" ) @@ -40,8 +43,8 @@ func (s *SetTaskRunner) GetTaskName() string { } func (s *SetTaskRunner) Run(input interface{}, taskSupport TaskSupport) (output interface{}, err error) { - setObject := deepClone(s.Task.Set) - result, err := traverseAndEvaluate(model.NewObjectOrRuntimeExpr(setObject), input, s.TaskName, taskSupport.GetContext()) + setObject := utils.DeepClone(s.Task.Set) + result, err := expr.TraverseAndEvaluateObj(model.NewObjectOrRuntimeExpr(setObject), input, s.TaskName, taskSupport.GetContext()) if err != nil { return nil, err } diff --git a/impl/task_set_test.go b/impl/task_runner_set_test.go similarity index 100% rename from impl/task_set_test.go rename to impl/task_runner_set_test.go diff --git a/.github/labeler.yml b/impl/testdata/fork_simple.yaml similarity index 51% rename from .github/labeler.yml rename to impl/testdata/fork_simple.yaml index 49abd17..044b1e2 100644 --- a/.github/labeler.yml +++ b/impl/testdata/fork_simple.yaml @@ -1,10 +1,10 @@ -# Copyright 2022 The Serverless Workflow Specification Authors +# 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 +# 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, @@ -12,9 +12,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -"documentation :notebook:": - - changed-files: - - any-glob-to-any-file: ['contrib/*', '**/*.md'] -kubernetes: - - changed-files: - - any-glob-to-any-file: ['kubernetes/*', 'hack/builder-gen.sh', 'hack/deepcopy-gen.sh', 'Makefile'] +document: + dsl: '1.0.0' + namespace: test + name: fork-example + version: '0.1.0' +do: + - branchColors: + fork: + compete: false + branches: + - setRed: + set: + color1: red + - setBlue: + set: + color2: blue + - joinResult: + set: + colors: "${ [.[] | .[]] }" diff --git a/impl/utils.go b/impl/utils.go deleted file mode 100644 index a62559d..0000000 --- a/impl/utils.go +++ /dev/null @@ -1,79 +0,0 @@ -// 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" - - "github.com/serverlessworkflow/sdk-go/v3/impl/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{}) - 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 -} - -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, wfCtx context.Context) (output interface{}, err error) { - if runtimeExpr == nil { - return input, nil - } - output, err = expr.TraverseAndEvaluate(runtimeExpr.AsStringOrMap(), input, wfCtx) - if err != nil { - return nil, model.NewErrExpression(err, taskName) - } - return output, nil -} - -func traverseAndEvaluateBool(runtimeExpr string, input interface{}, wfCtx context.Context) (bool, error) { - if len(runtimeExpr) == 0 { - return false, nil - } - output, err := expr.TraverseAndEvaluate(runtimeExpr, input, wfCtx) - if err != nil { - return false, nil - } - if result, ok := output.(bool); ok { - return result, nil - } - return false, nil -} diff --git a/impl/json_schema.go b/impl/utils/json_schema.go similarity index 84% rename from impl/json_schema.go rename to impl/utils/json_schema.go index 396f9f5..9b91553 100644 --- a/impl/json_schema.go +++ b/impl/utils/json_schema.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package impl +package utils import ( "encoding/json" @@ -23,8 +23,8 @@ import ( "github.com/xeipuuv/gojsonschema" ) -// ValidateJSONSchema validates the provided data against a model.Schema. -func ValidateJSONSchema(data interface{}, schema *model.Schema) error { +// validateJSONSchema validates the provided data against a model.Schema. +func validateJSONSchema(data interface{}, schema *model.Schema) error { if schema == nil { return nil } @@ -68,3 +68,12 @@ func ValidateJSONSchema(data interface{}, schema *model.Schema) error { return 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 +} diff --git a/impl/utils/utils.go b/impl/utils/utils.go new file mode 100644 index 0000000..f444139 --- /dev/null +++ b/impl/utils/utils.go @@ -0,0 +1,38 @@ +// 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 utils + +// DeepClone 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 +} From edd5f5e89136c8bd756e6058cddda98e923f89d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Giovanny=20Guti=C3=A9rrez?= Date: Fri, 2 May 2025 09:57:44 -0500 Subject: [PATCH 08/11] fix: Endpoint configuration should also accept expressions (#225) Signed-off-by: Gio Gutierrez --- model/endpoint.go | 36 ++++++++++++++++++++++++++++++------ model/endpoint_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/model/endpoint.go b/model/endpoint.go index 38e2cea..cd9ee88 100644 --- a/model/endpoint.go +++ b/model/endpoint.go @@ -95,8 +95,9 @@ func (u *LiteralUri) GetValue() interface{} { } type EndpointConfiguration struct { - URI URITemplate `json:"uri" validate:"required"` - Authentication *ReferenceableAuthenticationPolicy `json:"authentication,omitempty"` + RuntimeExpression *RuntimeExpression `json:"-"` + URI URITemplate `json:"uri" validate:"required"` + Authentication *ReferenceableAuthenticationPolicy `json:"authentication,omitempty"` } // UnmarshalJSON implements custom unmarshalling for EndpointConfiguration. @@ -116,12 +117,35 @@ func (e *EndpointConfiguration) UnmarshalJSON(data []byte) error { // Unmarshal the URI field into the appropriate URITemplate implementation uri, err := UnmarshalURITemplate(temp.URI) - if err != nil { - return fmt.Errorf("invalid URI in EndpointConfiguration: %w", err) + if err == nil { + e.URI = uri + return nil + } + + var runtimeExpr RuntimeExpression + if err := json.Unmarshal(temp.URI, &runtimeExpr); err == nil && runtimeExpr.IsValid() { + e.RuntimeExpression = &runtimeExpr + return nil } - e.URI = uri - return nil + return errors.New("failed to unmarshal EndpointConfiguration: data does not match any known schema") +} + +// MarshalJSON implements custom marshalling for Endpoint. +func (e *EndpointConfiguration) MarshalJSON() ([]byte, error) { + m := make(map[string]interface{}) + if e.Authentication != nil { + m["authentication"] = e.Authentication + } + + if e.RuntimeExpression != nil { + m["uri"] = e.RuntimeExpression + } else if e.URI != nil { + m["uri"] = e.URI + } + + // Return an empty JSON object when no fields are set + return json.Marshal(m) } type Endpoint struct { diff --git a/model/endpoint_test.go b/model/endpoint_test.go index 974216e..db2fce5 100644 --- a/model/endpoint_test.go +++ b/model/endpoint_test.go @@ -71,6 +71,48 @@ func TestEndpoint_UnmarshalJSON(t *testing.T) { assert.Equal(t, "admin", endpoint.EndpointConfig.Authentication.AuthenticationPolicy.Basic.Password, "Authentication Password should match") }) + t.Run("Valid EndpointConfiguration with reference", func(t *testing.T) { + input := `{ + "uri": "http://example.com/{id}", + "authentication": { + "oauth2": { "use": "secret" } + } + }` + + var endpoint Endpoint + err := json.Unmarshal([]byte(input), &endpoint) + + assert.NoError(t, err, "Unmarshal should not return an error") + assert.NotNil(t, endpoint.EndpointConfig, "EndpointConfig should be set") + assert.NotNil(t, endpoint.EndpointConfig.URI, "EndpointConfig URI should be set") + assert.Nil(t, endpoint.EndpointConfig.RuntimeExpression, "EndpointConfig Expression should not be set") + assert.Equal(t, "secret", endpoint.EndpointConfig.Authentication.AuthenticationPolicy.OAuth2.Use, "Authentication secret should match") + b, err := json.Marshal(&endpoint) + assert.NoError(t, err, "Marshal should not return an error") + assert.JSONEq(t, input, string(b), "Output JSON should match") + }) + + t.Run("Valid EndpointConfiguration with reference and expression", func(t *testing.T) { + input := `{ + "uri": "${example}", + "authentication": { + "oauth2": { "use": "secret" } + } + }` + + var endpoint Endpoint + err := json.Unmarshal([]byte(input), &endpoint) + + assert.NoError(t, err, "Unmarshal should not return an error") + assert.NotNil(t, endpoint.EndpointConfig, "EndpointConfig should be set") + assert.Nil(t, endpoint.EndpointConfig.URI, "EndpointConfig URI should not be set") + assert.NotNil(t, endpoint.EndpointConfig.RuntimeExpression, "EndpointConfig Expression should be set") + assert.Equal(t, "secret", endpoint.EndpointConfig.Authentication.AuthenticationPolicy.OAuth2.Use, "Authentication secret should match") + b, err := json.Marshal(&endpoint) + assert.NoError(t, err, "Marshal should not return an error") + assert.JSONEq(t, input, string(b), "Output JSON should match") + }) + t.Run("Invalid JSON Structure", func(t *testing.T) { input := `{"invalid": "data"}` var endpoint Endpoint From 7a905eb18fa22a99729a3f3e8ce26555dd1ddf41 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Fri, 2 May 2025 11:02:11 -0400 Subject: [PATCH 09/11] Update README releases table --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1a6654e..2aa64b5 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ This table indicates the current state of implementation of various SDK features | [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) | +| [v2.5.0](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.5.0) | [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) | --- From e245973ce94a89710eda9332c2bfc2c0aae28461 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Fri, 2 May 2025 11:04:22 -0400 Subject: [PATCH 10/11] Update implementation Roadmap - Add Fork --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2aa64b5..296fcde 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ The table below lists the current state of this implementation. This table is a | Task Do | βœ… | | Task Emit | ❌ | | Task For | βœ… | -| Task Fork | ❌ | +| Task Fork | βœ… | | Task Listen | ❌ | | Task Raise | βœ… | | Task Run | ❌ | From 592f31d64f24a3afd4d10040ae5a488f9600158b Mon Sep 17 00:00:00 2001 From: Ricardo Zanini <1538000+ricardozanini@users.noreply.github.com> Date: Fri, 2 May 2025 11:06:38 -0400 Subject: [PATCH 11/11] Prepare v3.1.0 release --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 296fcde..36f11c8 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ This table indicates the current state of implementation of various SDK features | [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.5.0](https://github.com/serverlessworkflow/sdk-go/releases/tag/v2.5.0) | [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) | +| [v3.1.0](https://github.com/serverlessworkflow/sdk-go/releases/tag/v3.1.0) | [v1.0.0](https://github.com/serverlessworkflow/specification/releases/tag/v1.0.0) | --- pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy