diff --git a/README.md b/README.md index 429fcdd..8371d22 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ GoSteps is a go library that helps in running functions as steps and reminds you The idea behind `gosteps` is to define set of functions as chain-of-steps and execute them in a sequential fashion. > [!NOTE] -> go-steps v1 is a breaking change and older v0 models wont work with the new version. For v0 documentation, examples refer to [v0.3.0-beta documentation](https://github.com/TanmoySG/go-steps/tree/v0.3.0-beta) or v0 guide [here](./v0/README.md). +> go-steps v1 is a breaking change and older v0 models won't work with the new version. To continue to use the v0, you can either use the `v0` sub-package of the library or the `v0.3.0` tag. For usage documentation of `v0`, refer to the [v0 README](./v0/README.md). ## Usage @@ -385,7 +385,3 @@ func(c gosteps.GoStepsCtx) gosteps.StepResult { ### Example Sample code can be found in the [example](./example/) directory. - -### Help - -If you want to help fix the above constraint or other bugs/issues, feel free to raise an Issue or Pull Request with the changes. It'd be an immense help! diff --git a/example/multistep-example/main.go b/example/main.go similarity index 100% rename from example/multistep-example/main.go rename to example/main.go diff --git a/example/multistep-example/diag.png b/example/v0/dynamic-steps-example/diag.png similarity index 100% rename from example/multistep-example/diag.png rename to example/v0/dynamic-steps-example/diag.png diff --git a/example/v0/dynamic-steps-example/main.go b/example/v0/dynamic-steps-example/main.go new file mode 100644 index 0000000..ac0745a --- /dev/null +++ b/example/v0/dynamic-steps-example/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + + gosteps "github.com/TanmoySG/go-steps/v0" + "github.com/TanmoySG/go-steps/example/v0/funcs" +) + +func main() { + + intsToAdd := []int{1, 4, 7, 10} + + var step *gosteps.Step + for _, val := range intsToAdd { + step = addStepToChain(step, funcs.Add, []interface{}{val}) + } + + finalOutput, err := step.Execute(1) + if err != nil { + fmt.Printf("error executing steps: %s, final output: [%s]\n", err, finalOutput) + } + + fmt.Printf("Final Output: [%v]\n", finalOutput) +} + +// step to add new next step to step-chain; basically a linked-list insertion +func addStepToChain(step *gosteps.Step, stepFunc gosteps.StepFn, additionalArgs []interface{}) *gosteps.Step { + temp := gosteps.Step{ + Function: stepFunc, + StepArgs: additionalArgs, + } + + if step == nil { + step = &temp + return step + } + + curr := step + for curr.NextStep != nil { + curr = curr.NextStep + } + + curr.NextStep = &temp + return step +} diff --git a/example/v0/funcs/funcs.go b/example/v0/funcs/funcs.go new file mode 100644 index 0000000..3d58685 --- /dev/null +++ b/example/v0/funcs/funcs.go @@ -0,0 +1,42 @@ +package funcs + +import "fmt" + +var itr int = 1 + +func Add(args ...any) ([]interface{}, error) { + fmt.Printf("Adding %v\n", args) + return []interface{}{args[0].(int) + args[1].(int)}, nil +} + +func Sub(args ...any) ([]interface{}, error) { + fmt.Printf("Sub %v\n", args) + return []interface{}{args[0].(int) - args[1].(int)}, nil +} + +func Multiply(args ...any) ([]interface{}, error) { + fmt.Printf("Multiply %v\n", args) + return []interface{}{args[0].(int) * args[1].(int)}, nil +} + +func Divide(args ...any) ([]interface{}, error) { + fmt.Printf("Divide %v\n", args) + return []interface{}{args[0].(int) / args[1].(int)}, nil +} + +// Step will error 3times and return arg*30 and arg*31 on the 4th try +func StepWillError3Times(args ...any) ([]interface{}, error) { + fmt.Printf("Running fake error function for arg [%v]\n", args) + if itr == 3 { + return []interface{}{args[0].(int) * 30, args[0].(int) * 50}, nil + } + + itr += 1 + return nil, fmt.Errorf("error to retry") +} + +// Step will error infinitely +func StepWillErrorInfinitely(args ...any) ([]interface{}, error) { + fmt.Printf("Running infinite fake error function for arg [%v]\n", args) + return nil, fmt.Errorf("error to retry") +} diff --git a/example/v0/multistep-example/diag.png b/example/v0/multistep-example/diag.png new file mode 100644 index 0000000..af39f13 Binary files /dev/null and b/example/v0/multistep-example/diag.png differ diff --git a/example/v0/multistep-example/main.go b/example/v0/multistep-example/main.go new file mode 100644 index 0000000..a55190d --- /dev/null +++ b/example/v0/multistep-example/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "time" + + gosteps "github.com/TanmoySG/go-steps/v0" + "github.com/TanmoySG/go-steps/example/v0/funcs" +) + +const ( + stepMultiply = "Multiply" + stepDivide = "Divide" +) + +// reading/maintaining this is a bit tricky will add +// a functional way to create this in the next version +var steps = gosteps.Step{ + Function: funcs.Add, + StepArgs: []interface{}{2}, + NextStep: &gosteps.Step{ + Function: funcs.Sub, + StepArgs: []interface{}{4}, + NextStepResolver: nextStepResolver, + PossibleNextSteps: gosteps.PossibleNextSteps{ + { + Name: stepMultiply, + Function: funcs.Multiply, + StepArgs: []interface{}{-5}, + NextStep: &gosteps.Step{ + Function: funcs.Add, + StepArgs: []interface{}{100}, + NextStep: &gosteps.Step{ + Function: funcs.StepWillError3Times, + ErrorsToRetry: []error{ + fmt.Errorf("error"), + }, + NextStep: &gosteps.Step{ + Function: funcs.StepWillErrorInfinitely, + ErrorsToRetry: []error{ + fmt.Errorf("error"), + }, + NextStep: &gosteps.Step{ + Function: funcs.Multiply, + }, + StrictErrorCheck: false, + MaxAttempts: 5, // use gosteps.MaxMaxAttempts for Maximum Possible reattempts + }, + MaxAttempts: 5, + RetrySleep: 1 * time.Second, + }, + }, + }, + { + Name: stepDivide, + Function: funcs.Divide, + StepArgs: []interface{}{-2}, + }, + }, + }, +} + +func main() { + initArgs := []interface{}{5} + finalOutput, err := steps.Execute(initArgs...) + if err != nil { + fmt.Printf("error executing steps: %s, final output: [%s]\n", err, finalOutput) + } + + fmt.Printf("Final Output: [%v]\n", finalOutput) +} + +// step resolver +func nextStepResolver(args ...any) string { + if args[0].(int) < 0 { + fmt.Printf("StepResolver [%v]: Arguments is Negative, going with Multiply\n", args) + return stepMultiply + } + + fmt.Printf("StepResolver [%v]: Arguments is Positive, going with Divide\n", args) + return stepDivide +} diff --git a/v0/go_step_types.go b/v0/go_step_types.go new file mode 100644 index 0000000..e1cd6db --- /dev/null +++ b/v0/go_step_types.go @@ -0,0 +1,31 @@ +package v0 + +import "time" + +// StepName type defined the name of the step +type StepName string + +// StepFn type defines the Step's Function +type StepFn func(...interface{}) ([]interface{}, error) + +// PossibleNextSteps type is a list/array of Step objects +type PossibleNextSteps []Step + +// Step type defines a step with all configurations for the step +type Step struct { + Name StepName + Function StepFn + UseArguments stepArgChainingType + StepArgs []interface{} + NextStep *Step + PossibleNextSteps PossibleNextSteps + NextStepResolver interface{} + ErrorsToRetry []error + StrictErrorCheck bool + SkipRetry bool + MaxAttempts int + RetrySleep time.Duration +} + +// enum type for step arguments chaining +type stepArgChainingType string diff --git a/v0/go_steps.go b/v0/go_steps.go new file mode 100644 index 0000000..2c8f58d --- /dev/null +++ b/v0/go_steps.go @@ -0,0 +1,134 @@ +package v0 + +import ( + "fmt" + "strings" + "time" +) + +func (step *Step) Execute(initArgs ...any) ([]interface{}, error) { + // final output for step execution + var finalOutput []interface{} + + // initialize step output and step error + var stepOutput []interface{} + var stepError error + + // no initial step or function + if step == nil || step.Function == nil { + return nil, nil + } + + // entry step + var isEntryStep bool = true + + // step reattepts + var stepReAttemptsLeft int = step.MaxAttempts + + for { + // piping output from previous step as arguments for current step + var stepArgs []interface{} + + // only runs for first step in step + if isEntryStep { + step.StepArgs = append(step.StepArgs, initArgs...) + isEntryStep = false + } + + // resolve step arguments based on step.UseArguments + stepArgs = step.resolveStepArguments(stepOutput) + + // execute current step passing step arguments + stepOutput, stepError = step.Function(stepArgs...) + if stepError != nil { + if !step.SkipRetry && step.shouldRetry(stepError) && stepReAttemptsLeft > 0 { + // piping args as output for re-running same step + stepOutput = stepArgs + + // decrementing re-attempts left for current run + stepReAttemptsLeft -= 1 + + // sleep step.RetrySleep duration if set + if step.RetrySleep > 0 { + time.Sleep(step.RetrySleep) + } + + continue + } + + // skip retry as step error not retryable + // return output of previous step and error + return stepArgs, stepError + } + + // no next step, this is the final step + if step.NextStep == nil && step.PossibleNextSteps == nil { + finalOutput = stepOutput + return finalOutput, nil + } + + // next step is dependant on conditions + if step.PossibleNextSteps != nil && step.NextStepResolver != nil { + nextStepName := step.NextStepResolver.(func(...interface{}) string)(stepOutput...) + resolvedStep := step.resolveNextStep(StepName(nextStepName)) + if resolvedStep == nil { + return stepOutput, fmt.Errorf(unresolvedStepError, step.Name) + } + step.NextStep = resolvedStep + } + + // set step as resolved or default nextStep + step = step.NextStep + + // if step.MaxAttempts is not set, set default max value + if step.MaxAttempts < 1 { + step.MaxAttempts = DefaultMaxAttempts + } + + // reset step re-attempts + stepReAttemptsLeft = step.MaxAttempts - 1 + } +} + +// should retry for error +func (step Step) shouldRetry(err error) bool { + for _, errorToRetry := range step.ErrorsToRetry { + if step.StrictErrorCheck && err.Error() == errorToRetry.Error() { + return true + } else if !step.StrictErrorCheck && strings.Contains(errorToRetry.Error(), err.Error()) { + return true + } + } + + return false +} + +// resolve next step by step name +func (step Step) resolveNextStep(stepName StepName) *Step { + for _, nextStep := range step.PossibleNextSteps { + if nextStep.Name == stepName { + return &nextStep + } + } + + return nil +} + +func (step Step) resolveStepArguments(previousStepReturns []interface{}) []interface{} { + var resolvedStepArgs []interface{} + + switch step.UseArguments { + case PreviousStepReturns: + resolvedStepArgs = previousStepReturns + case CurrentStepArgs: + resolvedStepArgs = step.StepArgs + case PreviousReturnsWithCurrentStepArgs: + resolvedStepArgs = append(resolvedStepArgs, previousStepReturns...) + resolvedStepArgs = append(resolvedStepArgs, step.StepArgs...) + default: // covers UseCurrentStepArgsWithPreviousReturns too + resolvedStepArgs = append(resolvedStepArgs, step.StepArgs...) + resolvedStepArgs = append(resolvedStepArgs, previousStepReturns...) + } + + return resolvedStepArgs +} diff --git a/v0/go_steps_constants.go b/v0/go_steps_constants.go new file mode 100644 index 0000000..1d5d3c1 --- /dev/null +++ b/v0/go_steps_constants.go @@ -0,0 +1,30 @@ +package v0 + +import ( + "math" +) + +const ( + // to avoid infinite runs due to the MaxAttempts not being set, we're keeping the default attempts to 100 + // if required, import and use the MaxMaxAttempts in the step.MaxAttempts field + DefaultMaxAttempts = 100 + + // the Max value is 9223372036854775807, which is not infinite but a huge number of attempts + MaxMaxAttempts = math.MaxInt +) + +var ( + // only previous step return will be passed to current step as arguments + PreviousStepReturns stepArgChainingType = "PreviousStepReturns" + + // only current step arguments (StepArgs) will be passed to current step as arguments + CurrentStepArgs stepArgChainingType = "CurrentStepArgs" + + // both previous step returns and current step arguments (StepArgs) will be passed + // to current step as arguments - previous step returns, followed by current step args, + PreviousReturnsWithCurrentStepArgs stepArgChainingType = "PreviousReturnsWithCurrentStepArgs" + + // both previous step returns and current step arguments (StepArgs) will be passed + // to current step as arguments - current step args, followed by previous step returns + CurrentStepArgsWithPreviousReturns stepArgChainingType = "CurrentStepArgsWithPreviousReturns" +) diff --git a/v0/go_steps_errors.go b/v0/go_steps_errors.go new file mode 100644 index 0000000..290da04 --- /dev/null +++ b/v0/go_steps_errors.go @@ -0,0 +1,5 @@ +package v0 + +var ( + unresolvedStepError = "error: step [%s] is unresolved, no step found with this name." +) diff --git a/v0/go_steps_test.go b/v0/go_steps_test.go new file mode 100644 index 0000000..a411cd7 --- /dev/null +++ b/v0/go_steps_test.go @@ -0,0 +1,126 @@ +package v0 + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_resolveStepArguments(t *testing.T) { + + samplePreviousStepOutput := []interface{}{4} + sampleStepArgs := []interface{}{5} + + testCases := []struct { + useArguments stepArgChainingType + stepArgs []interface{} + previousStepOutput []interface{} + expectedResolvedStepArguments []interface{} + }{ + { + useArguments: PreviousStepReturns, + stepArgs: sampleStepArgs, + previousStepOutput: samplePreviousStepOutput, + expectedResolvedStepArguments: samplePreviousStepOutput, + }, + { + useArguments: CurrentStepArgs, + stepArgs: sampleStepArgs, + previousStepOutput: samplePreviousStepOutput, + expectedResolvedStepArguments: sampleStepArgs, + }, + { + useArguments: PreviousReturnsWithCurrentStepArgs, + stepArgs: sampleStepArgs, + previousStepOutput: samplePreviousStepOutput, + expectedResolvedStepArguments: []interface{}{4, 5}, + }, + { + useArguments: CurrentStepArgsWithPreviousReturns, + stepArgs: sampleStepArgs, + previousStepOutput: samplePreviousStepOutput, + expectedResolvedStepArguments: []interface{}{5, 4}, + }, + } + + for _, tc := range testCases { + step := Step{ + StepArgs: tc.stepArgs, + UseArguments: tc.useArguments, + } + + resolvedStepArgs := step.resolveStepArguments(tc.previousStepOutput) + + assert.Equal(t, tc.expectedResolvedStepArguments, resolvedStepArgs) + } +} + +func Test_resolveNextStep(t *testing.T) { + step := Step{ + PossibleNextSteps: PossibleNextSteps{ + { + Name: StepName("stepA"), + }, + { + Name: StepName("stepB"), + }, + { + Name: StepName("stepC"), + }, + }, + } + + // happy path + resolvedStep := step.resolveNextStep("stepA") + assert.NotNil(t, resolvedStep) + + // step not found + resolvedStep = step.resolveNextStep("stepD") + assert.Nil(t, resolvedStep) +} + +func Test_shouldRetry(t *testing.T) { + + testCases := []struct { + StrictErrorCheck bool + ExpectedShouldRetry bool + ErrorToCheck error + }{ + { + StrictErrorCheck: false, + ErrorToCheck: fmt.Errorf("error"), + ExpectedShouldRetry: true, + }, + { + StrictErrorCheck: true, + ErrorToCheck: fmt.Errorf("error"), + ExpectedShouldRetry: false, + }, + { + StrictErrorCheck: true, + ErrorToCheck: fmt.Errorf("error1"), + ExpectedShouldRetry: true, + }, + { + StrictErrorCheck: false, + ErrorToCheck: fmt.Errorf("wont retry for this error"), + ExpectedShouldRetry: false, + }, + } + + for _, tc := range testCases { + step := Step{ + ErrorsToRetry: []error{ + fmt.Errorf("error1"), + fmt.Errorf("error2"), + fmt.Errorf("error3"), + }, + StrictErrorCheck: tc.StrictErrorCheck, + } + + shouldRetry := step.shouldRetry(tc.ErrorToCheck) + + assert.Equal(t, tc.ExpectedShouldRetry, shouldRetry) + } +}