-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #55 from ccremer/recorder
Add a pipeline recording feature to resolve dependencies for Steps
- Loading branch information
Showing
7 changed files
with
497 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
//go:build examples | ||
|
||
package examples | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"testing" | ||
|
||
pipeline "github.com/ccremer/go-command-pipeline" | ||
) | ||
|
||
type ClientContext struct { | ||
context.Context | ||
client *Client | ||
createClientFn pipeline.ActionFunc[*ClientContext] | ||
recorder *pipeline.DependencyRecorder[*ClientContext] | ||
} | ||
|
||
func TestExample_DependencyRecorder(t *testing.T) { | ||
token := "someverysecuretoken" | ||
ctx := &ClientContext{Context: context.Background(), recorder: pipeline.NewDependencyRecorder[*ClientContext]()} | ||
ctx.createClientFn = createClientFn(token) | ||
pipe := pipeline.NewPipeline[*ClientContext]().WithBeforeHooks(ctx.recorder.Record) | ||
pipe.WithSteps( | ||
pipe.NewStep("create client", ctx.createClientFn), | ||
pipe.NewStep("connect to API", connect), | ||
pipe.NewStep("get resource", getResource), | ||
) | ||
err := pipe.RunWithContext(ctx) | ||
if err != nil { | ||
t.Fatal(err) | ||
} | ||
} | ||
|
||
// Client is some sort of client used to connect with some API. | ||
type Client struct { | ||
Token string | ||
} | ||
|
||
func (c *Client) Connect() error { | ||
// some logic connect to API using token | ||
return nil | ||
} | ||
|
||
func (c *Client) GetResource() (string, error) { | ||
// some logic to get a resource from API, let's assume this is only possible after calling Connect(). | ||
// (arguably bad design for a client but let's roll with it for demo purposes) | ||
return "resource", nil | ||
} | ||
|
||
func createClientFn(token string) func(ctx *ClientContext) error { | ||
return func(ctx *ClientContext) error { | ||
ctx.client = &Client{Token: token} | ||
return nil | ||
} | ||
} | ||
|
||
func connect(ctx *ClientContext) error { | ||
// we need to check first if the client has been created, otherwise the client remains 'nil' and we'd run into a Nil pointer panic. | ||
// This allows us to declare a certain order for the steps at compile time, and checking them at runtime. | ||
if err := ctx.recorder.RequireDependencyByFuncName(ctx.createClientFn); err != nil { | ||
return err | ||
} | ||
return ctx.client.Connect() | ||
} | ||
|
||
func getResource(ctx *ClientContext) error { | ||
// We can check for preconditions more easily | ||
ctx.recorder.MustRequireDependencyByFuncName(connect, ctx.createClientFn) | ||
resource, err := ctx.client.GetResource() | ||
fmt.Println(resource) | ||
return err | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
package pipelinetest | ||
|
||
import ( | ||
"context" | ||
|
||
pipeline "github.com/ccremer/go-command-pipeline" | ||
) | ||
|
||
// NewNoResolver returns a pipeline.DependencyResolver that doesn't actually resolve anything. | ||
// This can be used for testing. | ||
func NewNoResolver[T context.Context]() pipeline.DependencyResolver[T] { | ||
return &NoResolver[T]{} | ||
} | ||
|
||
// NoResolver is a pipeline.DependencyResolver that doesn't actually resolve anything. | ||
// This can be used for testing. | ||
type NoResolver[T context.Context] struct{} | ||
|
||
func (d NoResolver[T]) Record(_ pipeline.Step[T]) { | ||
// noop | ||
} | ||
|
||
func (d NoResolver[T]) RequireDependencyByStepName(_ ...string) error { | ||
// noop | ||
return nil | ||
} | ||
|
||
func (d NoResolver[T]) MustRequireDependencyByStepName(_ ...string) { | ||
// noop | ||
} | ||
|
||
func (d NoResolver[T]) RequireDependencyByFuncName(_ ...pipeline.ActionFunc[T]) error { | ||
// noop | ||
return nil | ||
} | ||
|
||
func (d NoResolver[T]) MustRequireDependencyByFuncName(_ ...pipeline.ActionFunc[T]) { | ||
// noop | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
package pipeline | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"reflect" | ||
"runtime" | ||
"strings" | ||
) | ||
|
||
// Recorder Records the steps executed in a pipeline. | ||
type Recorder[T context.Context] interface { | ||
// Record adds the step to the execution Records. | ||
Record(step Step[T]) | ||
} | ||
|
||
// DependencyResolver provides means to query if a pipeline Step is satisfied as a dependency for another Step. | ||
// It is used together with Recorder. | ||
type DependencyResolver[T context.Context] interface { | ||
Recorder[T] | ||
// RequireDependencyByStepName checks if any of the given step names are present in the Records. | ||
// It returns nil if all given step names are in the Records in any order. | ||
RequireDependencyByStepName(stepNames ...string) error | ||
// MustRequireDependencyByStepName is RequireDependencyByStepName but any non-nil errors result in a panic. | ||
MustRequireDependencyByStepName(stepNames ...string) | ||
// RequireDependencyByFuncName checks if any of the given action functions are present in the Records. | ||
// It returns nil if all given functions are in the Records in any order. | ||
// Since functions aren't comparable for equality, the resolver attempts to compare them by name through reflection. | ||
RequireDependencyByFuncName(actions ...ActionFunc[T]) error | ||
// MustRequireDependencyByFuncName is RequireDependencyByFuncName but any non-nil errors result in a panic. | ||
MustRequireDependencyByFuncName(actions ...ActionFunc[T]) | ||
} | ||
|
||
// DependencyRecorder is a Recorder and DependencyResolver that tracks each Step executed and can be used to query if certain steps are in the Records. | ||
type DependencyRecorder[T context.Context] struct { | ||
// Records contains a slice of Steps that were run. | ||
// It contains also the last Step that failed with an error. | ||
Records []Step[T] | ||
} | ||
|
||
// NewDependencyRecorder returns a new instance of DependencyRecorder. | ||
func NewDependencyRecorder[T context.Context]() *DependencyRecorder[T] { | ||
return &DependencyRecorder[T]{Records: []Step[T]{}} | ||
} | ||
|
||
// Record implements Recorder. | ||
func (s *DependencyRecorder[T]) Record(step Step[T]) { | ||
s.Records = append(s.Records, step) | ||
} | ||
|
||
// RequireDependencyByStepName implements DependencyResolver.RequireDependencyByStepName. | ||
// A DependencyError is returned with a list of names that aren't in the Records. | ||
// Steps that share the same name are not distinguishable. | ||
func (s *DependencyRecorder[T]) RequireDependencyByStepName(stepNames ...string) error { | ||
if len(stepNames) == 0 { | ||
return nil | ||
} | ||
missing := make([]string, 0) | ||
for _, desiredName := range stepNames { | ||
found := false | ||
for _, step := range s.Records { | ||
if step.Name == desiredName { | ||
found = true | ||
break | ||
} | ||
} | ||
if !found { | ||
missing = append(missing, desiredName) | ||
} | ||
} | ||
if len(missing) == 0 { | ||
return nil | ||
} | ||
return fmt.Errorf("%w", &DependencyError{MissingSteps: missing}) | ||
} | ||
|
||
// MustRequireDependencyByStepName implements DependencyResolver.MustRequireDependencyByStepName. | ||
func (s *DependencyRecorder[T]) MustRequireDependencyByStepName(stepNames ...string) { | ||
err := s.RequireDependencyByStepName(stepNames...) | ||
if err != nil { | ||
panic(err) | ||
} | ||
} | ||
|
||
// RequireDependencyByFuncName implements DependencyResolver.RequireDependencyByFuncName. | ||
// | ||
// Direct function pointers can easily be compared: | ||
// | ||
// func myFunc(ctx context.Context) error { | ||
// return nil | ||
// } | ||
// ... | ||
// pipe.AddStep("test", myFunc) | ||
// ... | ||
// recorder.RequireDependencyByFuncName(myFunc) | ||
// | ||
// Note that you may experience unexpected behaviour when dealing with generative functions. | ||
// For example, the following snippet will not work, since the function names from 2 different call locations are different: | ||
// | ||
// generateFunc() func(ctx context.Context) error { | ||
// return func(ctx context.Context) error { | ||
// return nil | ||
// } | ||
// } | ||
// ... | ||
// pipe.AddStep("test", generateFunc()) | ||
// ... | ||
// recorder.RequireDependencyByFuncName(generateFunc()) // will end in an error | ||
// | ||
// As an alternative, you may store the generated function in a variable that is accessible from multiple locations: | ||
// | ||
// var genFunc = generateFunc() | ||
// ... | ||
// pipe.AddStep("test", genFunc()) | ||
// ... | ||
// recorder.RequireDependencyByFuncName(genFunc()) // works | ||
func (s *DependencyRecorder[T]) RequireDependencyByFuncName(actions ...ActionFunc[T]) error { | ||
if len(actions) == 0 { | ||
return nil | ||
} | ||
missing := make([]string, 0) | ||
for _, desiredAction := range actions { | ||
found := false | ||
desiredActionName := getFunctionName(desiredAction) | ||
for _, step := range s.Records { | ||
actionName := getFunctionName(step.Action) | ||
if actionName == desiredActionName { | ||
found = true | ||
break | ||
} | ||
} | ||
if !found { | ||
missing = append(missing, desiredActionName) | ||
} | ||
} | ||
if len(missing) == 0 { | ||
return nil | ||
} | ||
return fmt.Errorf("%w", &DependencyError{MissingSteps: missing}) | ||
} | ||
|
||
// MustRequireDependencyByFuncName implements DependencyResolver.MustRequireDependencyByFuncName. | ||
func (s *DependencyRecorder[T]) MustRequireDependencyByFuncName(actions ...ActionFunc[T]) { | ||
err := s.RequireDependencyByFuncName(actions...) | ||
if err != nil { | ||
panic(err) | ||
} | ||
} | ||
|
||
func getFunctionName(temp interface{}) string { | ||
value := reflect.ValueOf(temp) | ||
if value.Kind() != reflect.Func { | ||
panic(fmt.Errorf("given value is not a function: %v", temp)) | ||
} | ||
strs := runtime.FuncForPC(value.Pointer()).Name() | ||
return strs | ||
} | ||
|
||
// DependencyError is an error that indicates which steps did not satisfy dependency requirements. | ||
type DependencyError struct { | ||
// MissingSteps returns a slice of Step or ActionFunc names. | ||
MissingSteps []string | ||
} | ||
|
||
// Error returns a stringed list of steps that did not run either by Step or ActionFunc name. | ||
func (d *DependencyError) Error() string { | ||
joined := strings.Join(d.MissingSteps, ", ") | ||
return fmt.Sprintf("required steps did not run: [%s]", joined) | ||
} |
Oops, something went wrong.