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() {