diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e90391245..3b9ce819c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -15,5 +15,6 @@ "providers/ofrep": "0.1.5", "providers/prefab": "0.0.2", "tests/flagd": "1.4.1", - "providers/go-feature-flag-in-process": "0.1.0" + "providers/go-feature-flag-in-process": "0.1.0", + "providers/multi-provider": "0.0.3" } diff --git a/providers/multi-provider/Makefile b/providers/multi-provider/Makefile new file mode 100644 index 000000000..941e647cb --- /dev/null +++ b/providers/multi-provider/Makefile @@ -0,0 +1,10 @@ +.PHONY: generate test +GOPATH_LOC = ${GOPATH} + +generate: + go generate ./... + go mod download + mockgen -source=${GOPATH}/pkg/mod/github.com/open-feature/go-sdk@v1.13.1/openfeature/provider.go -package=mocks -destination=./internal/mocks/provider_mock.go + +test: + go test ./... \ No newline at end of file diff --git a/providers/multi-provider/README.md b/providers/multi-provider/README.md new file mode 100644 index 000000000..b2bab36e1 --- /dev/null +++ b/providers/multi-provider/README.md @@ -0,0 +1,84 @@ +OpenFeature Multi-Provider +------------ + +The Multi-Provider allows you to use multiple underlying providers as sources of flag data for the OpenFeature server SDK. +When a flag is being evaluated, the Multi-Provider will consult each underlying provider it is managing in order to +determine the final result. Different evaluation strategies can be defined to control which providers get evaluated and +which result is used. + +The Multi-Provider is a powerful tool for performing migrations between flag providers, or combining multiple providers +into a single feature flagging interface. For example: + +- **Migration**: When migrating between two providers, you can run both in parallel under a unified flagging interface. +As flags are added to the new provider, the Multi-Provider will automatically find and return them, falling back to the old provider +if the new provider does not have +- **Multiple Data Sources**: The Multi-Provider allows you to seamlessly combine many sources of flagging data, such as +environment variables, local files, database values and SaaS hosted feature management systems. + +# Installation + +```sh +go get github.com/open-feature/go-sdk-contrib/providers/multi-provider +go get github.com/open-feature/go-sdk +``` + +# Usage + +```go +import ( + "github.com/open-feature/go-sdk/openfeature" + mp "github.com/open-feature/go-sdk-contrib/providers/multi-provider" +) + +providers := make(mp.ProviderMap) +providers["providerA"] = providerA +providers["providerB"] = providerB +provider, err := mp.NewMultiProvider(providers, mp.StrategyFirstMatch, WithLogger(myLogger)) +openfeature.SetProvider(provider) +``` + +# Options + +- `WithTimeout` - the duration is used for the total timeout across parallel operations. If none is set it will default +to 5 seconds. This is not supported for `FirstMatch` yet, which executes sequentially +- `WithFallbackProvider` - Used for setting a fallback provider for the `Comparison` strategy +- `WithLogger` - Provides slog support + +# Strategies + +There are multiple strategies that can be used to determine the result returned to the caller. A strategy must be set at +initialization time. + +There are 3 strategies available currently: + +- _First Match_ +- _First Success_ +- _Comparison_ + +## First Match Strategy + +The first match strategy works by **sequentially** calling each provider in the order that they are provided to the mutli-provider. +The first provider that returns a result. It will try calling the next provider whenever it encounters a `FLAG_NOT_FOUND` +error. However, if a provider returns an error other than `FLAG_NOT_FOUND` the provider will stop and return the default +value along with setting the error details if a detailed request is issued. (allow changing this behavior?) + +## First Success Strategy + +The First Success strategy works by calling each provider in **parallel**. The first provider that returns a response +with no errors is returned and all other calls are cancelled. If no provider provides a successful result the default +value will be returned to the caller. + +## Comparison + +The Comparison strategy works by calling each provider in **parallel**. All results are collected from each provider and +then the resolved results are compared to each other. If they all agree then that value is returned. If not and a fallback +provider is specified then the fallback will be executed. If no fallback is configured then the default value will be +returned. If a provider returns `FLAG_NOT_FOUND` that is not included in the comparison. If all providers +return not found then the default value is returned. Finally, if any provider returns an error other than `FLAG_NOT_FOUND` +the evaluation immediately stops and that error result is returned. This strategy does NOT support `ObjectEvaluation` + +# Not Yet Implemented + +- Hooks support +- Event support +- Full slog support \ No newline at end of file diff --git a/providers/multi-provider/go.mod b/providers/multi-provider/go.mod new file mode 100644 index 000000000..a03020f08 --- /dev/null +++ b/providers/multi-provider/go.mod @@ -0,0 +1,18 @@ +module github.com/open-feature/go-sdk-contrib/providers/multi-provider + +go 1.23.0 + +require ( + github.com/open-feature/go-sdk v1.13.1 + github.com/stretchr/testify v1.9.0 + go.uber.org/mock v0.5.1 + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 + golang.org/x/sync v0.7.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/providers/multi-provider/go.sum b/providers/multi-provider/go.sum new file mode 100644 index 000000000..794fea8ad --- /dev/null +++ b/providers/multi-provider/go.sum @@ -0,0 +1,24 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/open-feature/go-sdk v1.13.1 h1:RJbS70eyi7Jd3Zm5bFnaahNKNDXn+RAVnctpGu+uPis= +github.com/open-feature/go-sdk v1.13.1/go.mod h1:O8r4mhgeRIsjJ0ZBXlnE0BtbT/79W44gQceR7K8KYgo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs= +go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/providers/multi-provider/internal/mocks/provider_mock.go b/providers/multi-provider/internal/mocks/provider_mock.go new file mode 100644 index 000000000..2f0675dd3 --- /dev/null +++ b/providers/multi-provider/internal/mocks/provider_mock.go @@ -0,0 +1,242 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: /Users/jordanblacker/go/pkg/mod/github.com/open-feature/go-sdk@v1.13.1/openfeature/provider.go +// +// Generated by this command: +// +// mockgen -source=/Users/jordanblacker/go/pkg/mod/github.com/open-feature/go-sdk@v1.13.1/openfeature/provider.go -package=mocks -destination=./internal/mocks/provider_mock.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + openfeature "github.com/open-feature/go-sdk/openfeature" + gomock "go.uber.org/mock/gomock" +) + +// MockFeatureProvider is a mock of FeatureProvider interface. +type MockFeatureProvider struct { + ctrl *gomock.Controller + recorder *MockFeatureProviderMockRecorder + isgomock struct{} +} + +// MockFeatureProviderMockRecorder is the mock recorder for MockFeatureProvider. +type MockFeatureProviderMockRecorder struct { + mock *MockFeatureProvider +} + +// NewMockFeatureProvider creates a new mock instance. +func NewMockFeatureProvider(ctrl *gomock.Controller) *MockFeatureProvider { + mock := &MockFeatureProvider{ctrl: ctrl} + mock.recorder = &MockFeatureProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFeatureProvider) EXPECT() *MockFeatureProviderMockRecorder { + return m.recorder +} + +// BooleanEvaluation mocks base method. +func (m *MockFeatureProvider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BooleanEvaluation", ctx, flag, defaultValue, evalCtx) + ret0, _ := ret[0].(openfeature.BoolResolutionDetail) + return ret0 +} + +// BooleanEvaluation indicates an expected call of BooleanEvaluation. +func (mr *MockFeatureProviderMockRecorder) BooleanEvaluation(ctx, flag, defaultValue, evalCtx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BooleanEvaluation", reflect.TypeOf((*MockFeatureProvider)(nil).BooleanEvaluation), ctx, flag, defaultValue, evalCtx) +} + +// FloatEvaluation mocks base method. +func (m *MockFeatureProvider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FloatEvaluation", ctx, flag, defaultValue, evalCtx) + ret0, _ := ret[0].(openfeature.FloatResolutionDetail) + return ret0 +} + +// FloatEvaluation indicates an expected call of FloatEvaluation. +func (mr *MockFeatureProviderMockRecorder) FloatEvaluation(ctx, flag, defaultValue, evalCtx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FloatEvaluation", reflect.TypeOf((*MockFeatureProvider)(nil).FloatEvaluation), ctx, flag, defaultValue, evalCtx) +} + +// Hooks mocks base method. +func (m *MockFeatureProvider) Hooks() []openfeature.Hook { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Hooks") + ret0, _ := ret[0].([]openfeature.Hook) + return ret0 +} + +// Hooks indicates an expected call of Hooks. +func (mr *MockFeatureProviderMockRecorder) Hooks() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Hooks", reflect.TypeOf((*MockFeatureProvider)(nil).Hooks)) +} + +// IntEvaluation mocks base method. +func (m *MockFeatureProvider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IntEvaluation", ctx, flag, defaultValue, evalCtx) + ret0, _ := ret[0].(openfeature.IntResolutionDetail) + return ret0 +} + +// IntEvaluation indicates an expected call of IntEvaluation. +func (mr *MockFeatureProviderMockRecorder) IntEvaluation(ctx, flag, defaultValue, evalCtx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IntEvaluation", reflect.TypeOf((*MockFeatureProvider)(nil).IntEvaluation), ctx, flag, defaultValue, evalCtx) +} + +// Metadata mocks base method. +func (m *MockFeatureProvider) Metadata() openfeature.Metadata { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Metadata") + ret0, _ := ret[0].(openfeature.Metadata) + return ret0 +} + +// Metadata indicates an expected call of Metadata. +func (mr *MockFeatureProviderMockRecorder) Metadata() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Metadata", reflect.TypeOf((*MockFeatureProvider)(nil).Metadata)) +} + +// ObjectEvaluation mocks base method. +func (m *MockFeatureProvider) ObjectEvaluation(ctx context.Context, flag string, defaultValue any, evalCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ObjectEvaluation", ctx, flag, defaultValue, evalCtx) + ret0, _ := ret[0].(openfeature.InterfaceResolutionDetail) + return ret0 +} + +// ObjectEvaluation indicates an expected call of ObjectEvaluation. +func (mr *MockFeatureProviderMockRecorder) ObjectEvaluation(ctx, flag, defaultValue, evalCtx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ObjectEvaluation", reflect.TypeOf((*MockFeatureProvider)(nil).ObjectEvaluation), ctx, flag, defaultValue, evalCtx) +} + +// StringEvaluation mocks base method. +func (m *MockFeatureProvider) StringEvaluation(ctx context.Context, flag, defaultValue string, evalCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StringEvaluation", ctx, flag, defaultValue, evalCtx) + ret0, _ := ret[0].(openfeature.StringResolutionDetail) + return ret0 +} + +// StringEvaluation indicates an expected call of StringEvaluation. +func (mr *MockFeatureProviderMockRecorder) StringEvaluation(ctx, flag, defaultValue, evalCtx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StringEvaluation", reflect.TypeOf((*MockFeatureProvider)(nil).StringEvaluation), ctx, flag, defaultValue, evalCtx) +} + +// MockStateHandler is a mock of StateHandler interface. +type MockStateHandler struct { + ctrl *gomock.Controller + recorder *MockStateHandlerMockRecorder + isgomock struct{} +} + +// MockStateHandlerMockRecorder is the mock recorder for MockStateHandler. +type MockStateHandlerMockRecorder struct { + mock *MockStateHandler +} + +// NewMockStateHandler creates a new mock instance. +func NewMockStateHandler(ctrl *gomock.Controller) *MockStateHandler { + mock := &MockStateHandler{ctrl: ctrl} + mock.recorder = &MockStateHandlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStateHandler) EXPECT() *MockStateHandlerMockRecorder { + return m.recorder +} + +// Init mocks base method. +func (m *MockStateHandler) Init(evaluationContext openfeature.EvaluationContext) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Init", evaluationContext) + ret0, _ := ret[0].(error) + return ret0 +} + +// Init indicates an expected call of Init. +func (mr *MockStateHandlerMockRecorder) Init(evaluationContext any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockStateHandler)(nil).Init), evaluationContext) +} + +// Shutdown mocks base method. +func (m *MockStateHandler) Shutdown() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Shutdown") +} + +// Shutdown indicates an expected call of Shutdown. +func (mr *MockStateHandlerMockRecorder) Shutdown() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Shutdown", reflect.TypeOf((*MockStateHandler)(nil).Shutdown)) +} + +// Status mocks base method. +func (m *MockStateHandler) Status() openfeature.State { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Status") + ret0, _ := ret[0].(openfeature.State) + return ret0 +} + +// Status indicates an expected call of Status. +func (mr *MockStateHandlerMockRecorder) Status() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockStateHandler)(nil).Status)) +} + +// MockEventHandler is a mock of EventHandler interface. +type MockEventHandler struct { + ctrl *gomock.Controller + recorder *MockEventHandlerMockRecorder + isgomock struct{} +} + +// MockEventHandlerMockRecorder is the mock recorder for MockEventHandler. +type MockEventHandlerMockRecorder struct { + mock *MockEventHandler +} + +// NewMockEventHandler creates a new mock instance. +func NewMockEventHandler(ctrl *gomock.Controller) *MockEventHandler { + mock := &MockEventHandler{ctrl: ctrl} + mock.recorder = &MockEventHandlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEventHandler) EXPECT() *MockEventHandlerMockRecorder { + return m.recorder +} + +// EventChannel mocks base method. +func (m *MockEventHandler) EventChannel() <-chan openfeature.Event { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EventChannel") + ret0, _ := ret[0].(<-chan openfeature.Event) + return ret0 +} + +// EventChannel indicates an expected call of EventChannel. +func (mr *MockEventHandlerMockRecorder) EventChannel() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EventChannel", reflect.TypeOf((*MockEventHandler)(nil).EventChannel)) +} diff --git a/providers/multi-provider/pkg/errors/aggregate-errors.go b/providers/multi-provider/pkg/errors/aggregate-errors.go new file mode 100644 index 000000000..035baa078 --- /dev/null +++ b/providers/multi-provider/pkg/errors/aggregate-errors.go @@ -0,0 +1,56 @@ +package errors + +import ( + "fmt" + "golang.org/x/exp/maps" + "strings" +) + +type ( + // ProviderError is how the error in the Init stage of a provider is reported. + ProviderError struct { + Err error + ProviderName string + } + + // AggregateError map that contains up to one error per provider within the multi-provider + AggregateError map[string]ProviderError +) + +var ( + _ error = (*ProviderError)(nil) + _ error = (AggregateError)(nil) +) + +func (e *ProviderError) Error() string { + return fmt.Sprintf("Provider %s: %s", e.ProviderName, e.Err.Error()) +} + +// NewAggregateError Creates a new AggregateError +func NewAggregateError(providerErrors []ProviderError) *AggregateError { + err := make(AggregateError) + for _, se := range providerErrors { + err[se.ProviderName] = se + } + return &err +} + +func (ae AggregateError) Error() string { + size := len(ae) + switch size { + case 0: + return "" + case 1: + for _, err := range ae { + return err.Error() + } + default: + errs := make([]string, 0, size) + for _, err := range maps.Values(ae) { + errs = append(errs, err.Error()) + } + return strings.Join(errs, ", ") + } + + return "" // This will never occur, switch is exhaustive +} diff --git a/providers/multi-provider/pkg/options.go b/providers/multi-provider/pkg/options.go new file mode 100644 index 000000000..bbc38154b --- /dev/null +++ b/providers/multi-provider/pkg/options.go @@ -0,0 +1,51 @@ +package multiprovider + +import ( + "github.com/open-feature/go-sdk-contrib/providers/multi-provider/pkg/strategies" + of "github.com/open-feature/go-sdk/openfeature" + "log/slog" + "time" +) + +// WithLogger Sets a logger to be used with slog for internal logging +func WithLogger(l *slog.Logger) Option { + return func(conf *Configuration) { + conf.logger = l + } +} + +// WithTimeout Set a timeout for the total runtime for evaluation of parallel strategies +func WithTimeout(d time.Duration) Option { + return func(conf *Configuration) { + conf.timeout = d + } +} + +// WithFallbackProvider Sets a fallback provider when using the StrategyComparison +func WithFallbackProvider(p of.FeatureProvider) Option { + return func(conf *Configuration) { + conf.fallbackProvider = p + conf.useFallback = true + } +} + +// WithCustomStrategy sets a custom strategy. This must be used in conjunction with StrategyCustom +func WithCustomStrategy(s strategies.Strategy) Option { + return func(conf *Configuration) { + conf.customStrategy = s + } +} + +// WithEventPublishing Enables event publishing (Not Yet Implemented) +func WithEventPublishing() Option { + return func(conf *Configuration) { + conf.publishEvents = true + } +} + +// WithoutEventPublishing Disables event publishing (this is the default, but included for explicit usage) +func WithoutEventPublishing() Option { + return func(conf *Configuration) { + conf.publishEvents = false + } +} diff --git a/providers/multi-provider/pkg/providers.go b/providers/multi-provider/pkg/providers.go new file mode 100644 index 000000000..c40703981 --- /dev/null +++ b/providers/multi-provider/pkg/providers.go @@ -0,0 +1,281 @@ +package multiprovider + +import ( + "context" + "errors" + "fmt" + "github.com/open-feature/go-sdk-contrib/providers/multi-provider/pkg/strategies" + "golang.org/x/sync/errgroup" + "log/slog" + "maps" + "slices" + "sync" + "time" + + mperr "github.com/open-feature/go-sdk-contrib/providers/multi-provider/pkg/errors" + + of "github.com/open-feature/go-sdk/openfeature" +) + +type ( + // MultiProvider Provider used for combining multiple providers + MultiProvider struct { + providers ProviderMap + metadata of.Metadata + events chan of.Event + status of.State + mu sync.RWMutex + strategy strategies.Strategy + logger *slog.Logger + } + + // Configuration MultiProvider's internal configuration + Configuration struct { + useFallback bool + fallbackProvider of.FeatureProvider + customStrategy strategies.Strategy + logger *slog.Logger + publishEvents bool + metadata *of.Metadata + timeout time.Duration + hooks []of.Hook // Not implemented yet + } + + // EvaluationStrategy Defines a strategy to use for resolving the result from multiple providers + EvaluationStrategy = string + // ProviderMap A map where the keys are names of providers and the values are the providers themselves + ProviderMap map[string]of.FeatureProvider + // Option Function used for setting Configuration via the options pattern + Option func(*Configuration) +) + +const ( + // StrategyFirstMatch First provider whose response that is not FlagNotFound will be returned. This is executed + // sequentially, and not in parallel. + StrategyFirstMatch EvaluationStrategy = strategies.StrategyFirstMatch + // StrategyFirstSuccess First provider response that is not an error will be returned. This is executed in parallel + StrategyFirstSuccess EvaluationStrategy = strategies.StrategyFirstSuccess + // StrategyComparison All providers are called in parallel. If all responses agree the value will be returned. + // Otherwise, the value from the designated fallback provider's response will be returned. The fallback provider + // will be assigned to the first provider registered. + StrategyComparison EvaluationStrategy = "comparison" + // StrategyCustom allows for using a custom Strategy implementation. If this is set you MUST use the WithCustomStrategy + // option to set it + StrategyCustom EvaluationStrategy = "strategy-custom" +) + +var _ of.FeatureProvider = (*MultiProvider)(nil) + +// AsNamedProviderSlice Converts the map into a slice of NamedProvider instances +func (m ProviderMap) AsNamedProviderSlice() []*strategies.NamedProvider { + s := make([]*strategies.NamedProvider, 0, len(m)) + for name, provider := range m { + s = append(s, &strategies.NamedProvider{Name: name, Provider: provider}) + } + + return s +} + +// Size The size of the map. This operates in O(n) time. +func (m ProviderMap) Size() int { + return len(m.AsNamedProviderSlice()) +} + +func (m ProviderMap) buildMetadata() of.Metadata { + var separator string + metaName := "MultiProvider {" + names := slices.Collect(maps.Keys(m)) + slices.Sort(names) + for _, name := range names { + metaName = fmt.Sprintf("%s%s%s: %s", metaName, separator, name, m[name].Metadata().Name) + if separator == "" { + separator = ", " + } + } + metaName += "}" + return of.Metadata{ + Name: metaName, + } +} + +// NewMultiProvider returns the unified interface of multiple providers for interaction. +func NewMultiProvider(providerMap ProviderMap, evaluationStrategy EvaluationStrategy, options ...Option) (*MultiProvider, error) { + if len(providerMap) == 0 { + return nil, errors.New("providerMap cannot be nil or empty") + } + // Validate Providers + for name, provider := range providerMap { + if name == "" { + return nil, errors.New("provider name cannot be the empty string") + } + + if provider == nil { + return nil, fmt.Errorf("provider %s cannot be nil", name) + } + } + + config := &Configuration{ + logger: slog.Default(), + } + + for _, opt := range options { + opt(config) + } + + var eventChannel chan of.Event + if config.publishEvents { + eventChannel = make(chan of.Event) + } + + logger := config.logger + if logger == nil { + logger = slog.Default() + } + + multiProvider := &MultiProvider{ + providers: providerMap, + events: eventChannel, + logger: logger, + metadata: providerMap.buildMetadata(), + } + + var zeroDuration time.Duration + if config.timeout == zeroDuration { + config.timeout = 5 * time.Second + } + + var strategy strategies.Strategy + switch evaluationStrategy { + case StrategyFirstMatch: + strategy = strategies.NewFirstMatchStrategy(multiProvider.Providers()) + case StrategyFirstSuccess: + strategy = strategies.NewFirstSuccessStrategy(multiProvider.Providers(), config.timeout) + case StrategyComparison: + strategy = strategies.NewComparisonStrategy(multiProvider.Providers(), config.fallbackProvider) + case StrategyCustom: + if config.customStrategy != nil { + strategy = config.customStrategy + } else { + return nil, fmt.Errorf("A custom strategy must be set via an option if StrategyCustom is set") + } + default: + return nil, fmt.Errorf("%s is an unknown evalutation strategy", strategy) + } + multiProvider.strategy = strategy + + return multiProvider, nil +} + +// Providers Returns slice of providers wrapped in NamedProvider structs +func (mp *MultiProvider) Providers() []*strategies.NamedProvider { + return mp.providers.AsNamedProviderSlice() +} + +// ProvidersByName Returns the internal ProviderMap of the MultiProvider +func (mp *MultiProvider) ProvidersByName() ProviderMap { + return mp.providers +} + +// EvaluationStrategy The current set strategy +func (mp *MultiProvider) EvaluationStrategy() string { + return mp.strategy.Name() +} + +// Metadata provides the name `multiprovider` and the names of each provider passed. +func (mp *MultiProvider) Metadata() of.Metadata { + return mp.metadata +} + +// Hooks returns a collection of.Hook defined by this provider +func (mp *MultiProvider) Hooks() []of.Hook { + // Hooks that should be included with the provider + return []of.Hook{} +} + +// BooleanEvaluation returns a boolean flag +func (mp *MultiProvider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail { + return mp.strategy.BooleanEvaluation(ctx, flag, defaultValue, evalCtx) +} + +// StringEvaluation returns a string flag +func (mp *MultiProvider) StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx of.FlattenedContext) of.StringResolutionDetail { + return mp.strategy.StringEvaluation(ctx, flag, defaultValue, evalCtx) +} + +// FloatEvaluation returns a float flag +func (mp *MultiProvider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx of.FlattenedContext) of.FloatResolutionDetail { + return mp.strategy.FloatEvaluation(ctx, flag, defaultValue, evalCtx) +} + +// IntEvaluation returns an int flag +func (mp *MultiProvider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx of.FlattenedContext) of.IntResolutionDetail { + return mp.strategy.IntEvaluation(ctx, flag, defaultValue, evalCtx) +} + +// ObjectEvaluation returns an object flag +func (mp *MultiProvider) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail { + return mp.strategy.ObjectEvaluation(ctx, flag, defaultValue, evalCtx) +} + +// Init will run the initialize method for all of provides and aggregate the errors. +func (mp *MultiProvider) Init(evalCtx of.EvaluationContext) error { + var eg errgroup.Group + + for name, provider := range mp.providers { + eg.Go(func() error { + stateHandle, ok := provider.(of.StateHandler) + if !ok { + return nil + } + if err := stateHandle.Init(evalCtx); err != nil { + return &mperr.ProviderError{ + Err: err, + ProviderName: name, + } + } + + return nil + }) + } + + if err := eg.Wait(); err != nil { + mp.mu.Lock() + defer mp.mu.Unlock() + mp.status = of.ErrorState + + return err + } + + mp.mu.Lock() + defer mp.mu.Unlock() + mp.status = of.ReadyState + return nil +} + +// Status the current status of the MultiProvider +func (mp *MultiProvider) Status() of.State { + mp.mu.RLock() + defer mp.mu.RUnlock() + return mp.status +} + +// Shutdown Shuts down all internal providers +func (mp *MultiProvider) Shutdown() { + var wg sync.WaitGroup + for _, provider := range mp.providers { + wg.Add(1) + go func(p of.FeatureProvider) { + defer wg.Done() + if stateHandle, ok := provider.(of.StateHandler); ok { + stateHandle.Shutdown() + } + }(provider) + } + + wg.Wait() +} + +// EventChannel the channel events are emitted on (Not Yet Implemented) +func (mp *MultiProvider) EventChannel() <-chan of.Event { + return mp.events +} diff --git a/providers/multi-provider/pkg/providers_test.go b/providers/multi-provider/pkg/providers_test.go new file mode 100644 index 000000000..d4efdf02a --- /dev/null +++ b/providers/multi-provider/pkg/providers_test.go @@ -0,0 +1,209 @@ +package multiprovider + +import ( + "errors" + "github.com/open-feature/go-sdk-contrib/providers/multi-provider/internal/mocks" + "github.com/open-feature/go-sdk-contrib/providers/multi-provider/pkg/strategies" + "github.com/open-feature/go-sdk/openfeature" + of "github.com/open-feature/go-sdk/openfeature" + imp "github.com/open-feature/go-sdk/openfeature/memprovider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "regexp" + "testing" +) + +func TestMultiProvider_ProvidersMethod(t *testing.T) { + testProvider1 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + testProvider2 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + + providers := make(ProviderMap) + providers["provider1"] = testProvider1 + providers["provider2"] = testProvider2 + + mp, err := NewMultiProvider(providers, strategies.StrategyFirstSuccess) + require.NoError(t, err) + + p := mp.Providers() + assert.Len(t, p, 2) + assert.Regexp(t, regexp.MustCompile("provider[1-2]"), p[0].Name) + assert.NotNil(t, p[0].Provider) + assert.Regexp(t, regexp.MustCompile("provider[1-2]"), p[1].Name) + assert.NotNil(t, p[1].Provider) +} + +func TestMultiProvider_NewMultiProvider(t *testing.T) { + t.Run("nil providerMap returns an error", func(t *testing.T) { + _, err := NewMultiProvider(nil, strategies.StrategyFirstMatch) + require.Errorf(t, err, "providerMap cannot be nil or empty") + }) + + t.Run("naming a provider the empty string returns an error", func(t *testing.T) { + providers := make(ProviderMap) + providers[""] = imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + _, err := NewMultiProvider(providers, strategies.StrategyFirstMatch) + require.Errorf(t, err, "provider name cannot be the empty string") + }) + + t.Run("nil provider within map returns an error", func(t *testing.T) { + providers := make(ProviderMap) + providers["provider1"] = nil + _, err := NewMultiProvider(providers, strategies.StrategyFirstMatch) + require.Errorf(t, err, "provider provider1 cannot be nil") + }) + + t.Run("unknown evaluation strategy returns an error", func(t *testing.T) { + providers := make(ProviderMap) + providers["provider1"] = imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + _, err := NewMultiProvider(providers, "unknown") + require.Errorf(t, err, "unknown is an unknown evaluation strategy") + }) + + t.Run("setting custom strategy without custom strategy option returns error", func(t *testing.T) { + providers := make(ProviderMap) + providers["provider1"] = imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + _, err := NewMultiProvider(providers, StrategyCustom) + require.Errorf(t, err, "A custom strategy must be set via an option if StrategyCustom is set") + }) + + t.Run("success", func(t *testing.T) { + providers := make(ProviderMap) + providers["provider1"] = imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + mp, err := NewMultiProvider(providers, StrategyComparison) + require.NoError(t, err) + assert.NotZero(t, mp) + }) + + t.Run("success with custom provider", func(t *testing.T) { + providers := make(ProviderMap) + providers["provider1"] = imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + ctrl := gomock.NewController(t) + strategy := strategies.NewMockStrategy(ctrl) + mp, err := NewMultiProvider(providers, StrategyCustom, WithCustomStrategy(strategy)) + require.NoError(t, err) + assert.NotZero(t, mp) + }) +} + +func TestMultiProvider_ProvidersByNamesMethod(t *testing.T) { + testProvider1 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + testProvider2 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + + providers := make(ProviderMap) + providers["provider1"] = testProvider1 + providers["provider2"] = testProvider2 + + mp, err := NewMultiProvider(providers, strategies.StrategyFirstMatch) + require.NoError(t, err) + + p := mp.ProvidersByName() + + assert.Equal(t, 2, p.Size()) + require.Contains(t, p, "provider1") + assert.Equal(t, p["provider1"], testProvider1) + require.Contains(t, p, "provider2") + assert.Equal(t, p["provider2"], testProvider2) +} + +func TestMultiProvider_MetaData(t *testing.T) { + testProvider1 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + ctrl := gomock.NewController(t) + testProvider2 := mocks.NewMockFeatureProvider(ctrl) + testProvider2.EXPECT().Metadata().Return(of.Metadata{ + Name: "MockProvider", + }) + + providers := make(ProviderMap) + providers["provider1"] = testProvider1 + providers["provider2"] = testProvider2 + + mp, err := NewMultiProvider(providers, strategies.StrategyFirstSuccess) + require.NoError(t, err) + + metadata := mp.Metadata() + require.NotZero(t, metadata) + assert.Equal(t, "MultiProvider {provider1: InMemoryProvider, provider2: MockProvider}", metadata.Name) +} + +func TestMultiProvider_Init(t *testing.T) { + ctrl := gomock.NewController(t) + + testProvider1 := mocks.NewMockFeatureProvider(ctrl) + testProvider1.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + testProvider2 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + testProvider3 := mocks.NewMockFeatureProvider(ctrl) + testProvider3.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + + providers := make(ProviderMap) + providers["provider1"] = testProvider1 + providers["provider2"] = testProvider2 + providers["provider3"] = testProvider3 + + mp, err := NewMultiProvider(providers, strategies.StrategyFirstMatch) + require.NoError(t, err) + + attributes := map[string]interface{}{ + "foo": "bar", + } + evalCtx := openfeature.NewTargetlessEvaluationContext(attributes) + + err = mp.Init(evalCtx) + require.NoError(t, err) + assert.Equal(t, of.ReadyState, mp.status) +} + +func TestMultiProvider_InitErrorWithProvider(t *testing.T) { + ctrl := gomock.NewController(t) + errProvider := mocks.NewMockFeatureProvider(ctrl) + errProvider.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + errHandler := mocks.NewMockStateHandler(ctrl) + errHandler.EXPECT().Init(gomock.Any()).Return(errors.New("test error")) + testProvider3 := struct { + of.FeatureProvider + of.StateHandler + }{ + errProvider, + errHandler, + } + + testProvider1 := mocks.NewMockFeatureProvider(ctrl) + testProvider1.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + testProvider2 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + + providers := make(ProviderMap) + providers["provider1"] = testProvider1 + providers["provider2"] = testProvider2 + providers["provider3"] = testProvider3 + + mp, err := NewMultiProvider(providers, strategies.StrategyFirstMatch) + require.NoError(t, err) + + attributes := map[string]interface{}{ + "foo": "bar", + } + evalCtx := openfeature.NewTargetlessEvaluationContext(attributes) + + err = mp.Init(evalCtx) + require.Errorf(t, err, "Provider provider1: test error") + assert.Equal(t, of.ErrorState, mp.status) +} + +func TestMultiProvider_Shutdown(t *testing.T) { + ctrl := gomock.NewController(t) + + testProvider1 := mocks.NewMockFeatureProvider(ctrl) + testProvider1.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + testProvider2 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) + testProvider3 := mocks.NewMockFeatureProvider(ctrl) + testProvider3.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + + providers := make(ProviderMap) + providers["provider1"] = testProvider1 + providers["provider2"] = testProvider2 + providers["provider3"] = testProvider3 + mp, err := NewMultiProvider(providers, strategies.StrategyFirstMatch) + require.NoError(t, err) + + mp.Shutdown() +} diff --git a/providers/multi-provider/pkg/strategies/comparison.go b/providers/multi-provider/pkg/strategies/comparison.go new file mode 100644 index 000000000..3b1c4df45 --- /dev/null +++ b/providers/multi-provider/pkg/strategies/comparison.go @@ -0,0 +1,346 @@ +package strategies + +import ( + "cmp" + "context" + "errors" + mperr "github.com/open-feature/go-sdk-contrib/providers/multi-provider/pkg/errors" + of "github.com/open-feature/go-sdk/openfeature" + "golang.org/x/sync/errgroup" + "slices" + "strings" +) + +const ( + MetadataIsDefault = "multiprovider-is-default-result" + MetadataNoneFound = "multiprovider-flag-not-found-all-providers" + MetadataEvaluationError = "multiprovider-comparison-first-error" +) + +type ( + ComparisonStrategy struct { + providers []*NamedProvider + fallbackProvider of.FeatureProvider + } + + comparator[R bool | string | int64 | float64] func(values []R) bool +) + +var _ Strategy = (*ComparisonStrategy)(nil) + +func NewComparisonStrategy(providers []*NamedProvider, fallbackProvider of.FeatureProvider) *ComparisonStrategy { + return &ComparisonStrategy{ + providers: providers, + fallbackProvider: fallbackProvider, + } +} + +func (c ComparisonStrategy) Name() EvaluationStrategy { + return StrategyComparison +} + +func (c ComparisonStrategy) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.BoolResolutionDetail] { + result := p.Provider.BooleanEvaluation(ctx, flag, defaultValue, evalCtx) + return resultWrapper[of.BoolResolutionDetail]{ + result: &result, + name: p.Name, + value: result.Value, + detail: result.ProviderResolutionDetail, + } + } + compFunc := func(values []bool) bool { + current := values[0] + match := true + for i, v := range values { + if i == 0 { + continue + } + if current != v { + match = false + break + } + } + + return match + } + results, metadata := evaluateComparison[of.BoolResolutionDetail, bool](ctx, c.providers, evalFunc, compFunc, c.fallbackProvider, defaultValue) + + return of.BoolResolutionDetail{ + Value: results[0].result.Value, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: comparisonResolutionError(metadata), + Reason: comparisonResolutionReason(metadata), + Variant: results[0].detail.Variant, + FlagMetadata: metadata, + }, + } +} + +func (c ComparisonStrategy) StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx of.FlattenedContext) of.StringResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.StringResolutionDetail] { + result := p.Provider.StringEvaluation(ctx, flag, defaultValue, evalCtx) + return resultWrapper[of.StringResolutionDetail]{ + result: &result, + name: p.Name, + value: result.Value, + detail: result.ProviderResolutionDetail, + } + } + compFunc := func(values []string) bool { + current := values[0] + match := true + for i, v := range values { + if i == 0 { + continue + } + if current != v { + match = false + break + } + } + + return match + } + + results, metadata := evaluateComparison[of.StringResolutionDetail, string](ctx, c.providers, evalFunc, compFunc, c.fallbackProvider, defaultValue) + return of.StringResolutionDetail{ + Value: results[0].result.Value, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: comparisonResolutionError(metadata), + Reason: comparisonResolutionReason(metadata), + Variant: results[0].detail.Variant, + FlagMetadata: metadata, + }, + } +} + +func (c ComparisonStrategy) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx of.FlattenedContext) of.FloatResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.FloatResolutionDetail] { + result := p.Provider.FloatEvaluation(ctx, flag, defaultValue, evalCtx) + return resultWrapper[of.FloatResolutionDetail]{ + result: &result, + name: p.Name, + value: result.Value, + detail: result.ProviderResolutionDetail, + } + } + compFunc := func(values []float64) bool { + current := values[0] + match := true + for i, v := range values { + if i == 0 { + continue + } + if current != v { + match = false + break + } + } + + return match + } + + results, metadata := evaluateComparison[of.FloatResolutionDetail, float64](ctx, c.providers, evalFunc, compFunc, c.fallbackProvider, defaultValue) + return of.FloatResolutionDetail{ + Value: results[0].result.Value, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: comparisonResolutionError(metadata), + Reason: comparisonResolutionReason(metadata), + Variant: results[0].detail.Variant, + FlagMetadata: metadata, + }, + } +} + +func (c ComparisonStrategy) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx of.FlattenedContext) of.IntResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.IntResolutionDetail] { + result := p.Provider.IntEvaluation(ctx, flag, defaultValue, evalCtx) + return resultWrapper[of.IntResolutionDetail]{ + result: &result, + name: p.Name, + value: result.Value, + detail: result.ProviderResolutionDetail, + } + } + compFunc := func(values []int64) bool { + current := values[0] + match := true + for i, v := range values { + if i == 0 { + continue + } + if current != v { + match = false + break + } + } + + return match + } + results, metadata := evaluateComparison[of.IntResolutionDetail, int64](ctx, c.providers, evalFunc, compFunc, c.fallbackProvider, defaultValue) + return of.IntResolutionDetail{ + Value: results[0].result.Value, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: comparisonResolutionError(metadata), + Reason: comparisonResolutionReason(metadata), + Variant: results[0].detail.Variant, + FlagMetadata: metadata, + }, + } +} + +func comparisonResolutionReason(metadata of.FlagMetadata) of.Reason { + reason := ReasonAggregated + if fallbackUsed, err := metadata.GetBool(MetadataFallbackUsed); fallbackUsed && err == nil { + reason = ReasonAggregatedFallback + } else if defaultUsed, err := metadata.GetBool(MetadataIsDefault); defaultUsed && err == nil { + reason = of.DefaultReason + } + return reason +} + +func comparisonResolutionError(metadata of.FlagMetadata) of.ResolutionError { + if isDefault, err := metadata.GetBool(MetadataIsDefault); err != nil || !isDefault { + return of.ResolutionError{} + } + + if notFound, err := metadata.GetBool(MetadataNoneFound); err == nil && notFound { + return of.NewFlagNotFoundResolutionError("not found in any providers") + } + + if evalErr, err := metadata.GetString(MetadataEvaluationError); evalErr != "" && err != nil { + return of.NewGeneralResolutionError(evalErr) + } + + return of.NewGeneralResolutionError("comparison failure") +} + +func (c ComparisonStrategy) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail { + metadata := make(of.FlagMetadata) + metadata[MetadataStrategyUsed] = StrategyComparison + metadata[MetadataSuccessfulProviderName] = "none" + metadata[MetadataFallbackUsed] = false + + return of.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewGeneralResolutionError(ErrAggregationNotAllowedText), + Reason: of.DefaultReason, + Variant: "", + FlagMetadata: metadata, + }, + } +} + +func evaluateComparison[R resultConstraint, DV bool | string | int64 | float64](ctx context.Context, providers []*NamedProvider, e evaluator[R], comp comparator[DV], fallbackProvider of.FeatureProvider, defaultVal DV) ([]resultWrapper[R], of.FlagMetadata) { + if len(providers) == 1 { + result := e(ctx, providers[0]) + metadata := setFlagMetadata(StrategyComparison, cmp.Or(result.name, providers[0].Name), make(of.FlagMetadata)) + metadata[MetadataFallbackUsed] = false + return []resultWrapper[R]{result}, metadata + } + + resultChan := make(chan *resultWrapper[R], len(providers)) + notFoundChan := make(chan interface{}) + errGrp, ctx := errgroup.WithContext(ctx) + for _, provider := range providers { + errGrp.Go(func() error { + localChan := make(chan *resultWrapper[R]) + + go func(c context.Context, p *NamedProvider) { + result := e(c, p) + localChan <- &result + }(ctx, provider) + + select { + case r := <-localChan: + notFound := r.detail.ResolutionDetail().ErrorCode == of.FlagNotFoundCode + if !notFound && r.detail.Error() != nil { + return &mperr.ProviderError{ + ProviderName: r.name, + Err: r.detail.Error(), + } + } + if !notFound { + resultChan <- r + } else { + notFoundChan <- struct{}{} + } + return nil + case <-ctx.Done(): + return nil + } + }) + } + + results := make([]resultWrapper[R], 0, len(providers)) + resultValues := make([]DV, 0, len(providers)) + notFoundCount := 0 + for { + select { + case <-ctx.Done(): + // Error occurred + result := buildDefaultResult[R, DV](StrategyComparison, defaultVal, ctx.Err()) + metadata := result.detail.FlagMetadata + metadata[MetadataFallbackUsed] = false + metadata[MetadataIsDefault] = true + metadata[MetadataEvaluationError] = ctx.Err().Error() + return []resultWrapper[R]{result}, metadata + case r := <-resultChan: + results = append(results, *r) + resultValues = append(resultValues, r.value.(DV)) + if (len(results) + notFoundCount) == len(providers) { + goto continueComparison + } + case <-notFoundChan: + notFoundCount += 1 + if notFoundCount == len(providers) { + result := buildDefaultResult[R, DV](StrategyComparison, defaultVal, ctx.Err()) + metadata := result.detail.FlagMetadata + metadata[MetadataFallbackUsed] = false + metadata[MetadataIsDefault] = true + return []resultWrapper[R]{result}, metadata + } + if (len(results) + notFoundCount) == len(providers) { + goto continueComparison + } + } + } +continueComparison: + // Evaluate Results Are Equal + metadata := make(of.FlagMetadata) + metadata[MetadataStrategyUsed] = StrategyComparison + agreement := comp(resultValues) + if agreement { + metadata[MetadataFallbackUsed] = false + metadata[MetadataIsDefault] = false + success := make([]string, 0, len(providers)) + for _, r := range results { + metadata[r.name] = r.detail.FlagMetadata + success = append(success, r.name) + } + // maintain stable order of metadata results + slices.Sort(success) + metadata[MetadataSuccessfulProviderName+"s"] = strings.Join(success, ", ") + return results, metadata + } + + if fallbackProvider != nil { + fallbackResult := e(ctx, &NamedProvider{Name: "fallback", Provider: fallbackProvider}) + metadata = fallbackResult.detail.FlagMetadata + metadata[MetadataFallbackUsed] = true + metadata[MetadataIsDefault] = false + metadata[MetadataSuccessfulProviderName] = "fallback" + metadata[MetadataStrategyUsed] = StrategyComparison + + return []resultWrapper[R]{fallbackResult}, metadata + } + + defaultResult := buildDefaultResult[R, DV](StrategyComparison, defaultVal, errors.New("no fallback provider configured")) + metadata = defaultResult.detail.FlagMetadata + metadata[MetadataFallbackUsed] = false + metadata[MetadataIsDefault] = true + + return []resultWrapper[R]{defaultResult}, metadata +} diff --git a/providers/multi-provider/pkg/strategies/comparison_test.go b/providers/multi-provider/pkg/strategies/comparison_test.go new file mode 100644 index 000000000..0a8eeab94 --- /dev/null +++ b/providers/multi-provider/pkg/strategies/comparison_test.go @@ -0,0 +1,1415 @@ +package strategies + +import ( + "context" + "github.com/open-feature/go-sdk-contrib/providers/multi-provider/internal/mocks" + of "github.com/open-feature/go-sdk/openfeature" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "testing" +) + +const ( + TestErrorNone = 0 + TestErrorNotFound = 1 + TestErrorError = 2 +) + +func configureComparisonProvider[R bool | int64 | float64 | string | interface{}](provider *mocks.MockFeatureProvider, resultVal R, state bool, error int) { + var rErr of.ResolutionError + var variant string + var reason of.Reason + switch error { + case TestErrorError: + rErr = of.NewGeneralResolutionError("test error") + reason = of.DisabledReason + case TestErrorNotFound: + rErr = of.NewFlagNotFoundResolutionError("not found") + reason = of.DefaultReason + } + if state { + variant = "on" + } else { + variant = "off" + } + details := of.ProviderResolutionDetail{ + ResolutionError: rErr, + Reason: reason, + Variant: variant, + FlagMetadata: make(of.FlagMetadata), + } + + switch any(resultVal).(type) { + case bool: + provider.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(c context.Context, flag string, defaultVal bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail { + return of.BoolResolutionDetail{ + Value: any(resultVal).(bool), + ProviderResolutionDetail: details, + } + }).MaxTimes(1) + case string: + provider.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(c context.Context, flag string, defaultVal string, evalCtx of.FlattenedContext) of.StringResolutionDetail { + return of.StringResolutionDetail{ + Value: any(resultVal).(string), + ProviderResolutionDetail: details, + } + }).MaxTimes(1) + case int64: + provider.EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(c context.Context, flag string, defaultVal int64, evalCtx of.FlattenedContext) of.IntResolutionDetail { + return of.IntResolutionDetail{ + Value: any(resultVal).(int64), + ProviderResolutionDetail: details, + } + }).MaxTimes(1) + case float64: + provider.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(c context.Context, flag string, defaultVal float64, evalCtx of.FlattenedContext) of.FloatResolutionDetail { + return of.FloatResolutionDetail{ + Value: any(resultVal).(float64), + ProviderResolutionDetail: details, + } + }).MaxTimes(1) + default: + provider.EXPECT().ObjectEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(c context.Context, flag string, defaultVal any, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail { + return of.InterfaceResolutionDetail{ + Value: resultVal, + ProviderResolutionDetail: details, + } + }).MaxTimes(1) + } +} + +func Test_ComparisonStrategy_BooleanEvaluation(t *testing.T) { + t.Run("single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + configureComparisonProvider(provider, true, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider", + Provider: provider, + }, + }, fallback) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "test-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("two success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, true, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, true, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider1, test-provider2", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, true, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, true, true, TestErrorNone) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, true, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider1, test-provider2, test-provider3", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple not found with single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, false, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, false, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, true, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider3", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple not found with multiple success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, false, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, false, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, true, true, TestErrorNone) + provider4 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider4, true, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + { + Name: "test-provider4", + Provider: provider4, + }, + }, fallback) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider3, test-provider4", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("comparison failure uses fallback", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(of.BoolResolutionDetail{ + Value: true, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.ResolutionError{}, + Variant: "on", + Reason: "", + FlagMetadata: make(of.FlagMetadata), + }, + }) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, false, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, false, true, TestErrorNone) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, true, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "fallback", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.True(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("comparison failure with not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(of.BoolResolutionDetail{ + Value: true, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.ResolutionError{}, + Variant: "on", + Reason: "", + FlagMetadata: make(of.FlagMetadata), + }, + }) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, false, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, false, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, true, true, TestErrorNone) + provider4 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider4, false, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + { + Name: "test-provider4", + Provider: provider4, + }, + }, fallback) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "fallback", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.True(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("not found all providers", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, false, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, false, true, TestErrorNotFound) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.False(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("non FLAG_NOT_FOUND error causes default", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, true, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, false, true, TestErrorError) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.False(t, result.Value) + assert.Equal(t, of.DefaultReason, result.Reason) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + + }) +} + +func Test_ComparisonStrategy_StringEvaluation(t *testing.T) { + successVal := "success" + defaultVal := "default" + t.Run("single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + configureComparisonProvider(provider, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider", + Provider: provider, + }, + }, fallback) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "test-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("two success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, successVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider1, test-provider2", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, successVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, successVal, true, TestErrorNone) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider1, test-provider2, test-provider3", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple not found with single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider3", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple not found with multiple success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + provider4 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider4, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + { + Name: "test-provider4", + Provider: provider4, + }, + }, fallback) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider3, test-provider4", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("comparison failure uses fallback", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(of.StringResolutionDetail{ + Value: successVal, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.ResolutionError{}, + Variant: "on", + Reason: "", + FlagMetadata: make(of.FlagMetadata), + }, + }) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNone) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "fallback", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.True(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("comparison failure with not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(of.StringResolutionDetail{ + Value: successVal, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.ResolutionError{}, + Variant: "on", + Reason: "", + FlagMetadata: make(of.FlagMetadata), + }, + }) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + provider4 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider4, defaultVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + { + Name: "test-provider4", + Provider: provider4, + }, + }, fallback) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "fallback", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.True(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("not found all providers", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("non FLAG_NOT_FOUND error causes default", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, successVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorError) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, of.DefaultReason, result.Reason) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) +} + +func Test_ComparisonStrategy_IntEvaluation(t *testing.T) { + successVal := int64(1234) + defaultVal := int64(0) + t.Run("single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + configureComparisonProvider(provider, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider", + Provider: provider, + }, + }, fallback) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "test-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("two success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, successVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider1, test-provider2", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, successVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, successVal, true, TestErrorNone) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider1, test-provider2, test-provider3", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple not found with single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider3", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple not found with multiple success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + provider4 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider4, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + { + Name: "test-provider4", + Provider: provider4, + }, + }, fallback) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider3, test-provider4", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("comparison failure uses fallback", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(of.IntResolutionDetail{ + Value: successVal, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.ResolutionError{}, + Variant: "on", + Reason: "", + FlagMetadata: make(of.FlagMetadata), + }, + }) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNone) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "fallback", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.True(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("not found all providers", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("comparison failure with not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(of.IntResolutionDetail{ + Value: successVal, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.ResolutionError{}, + Variant: "on", + Reason: "", + FlagMetadata: make(of.FlagMetadata), + }, + }) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + provider4 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider4, defaultVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + { + Name: "test-provider4", + Provider: provider4, + }, + }, fallback) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "fallback", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.True(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("non FLAG_NOT_FOUND error causes default", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, successVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorError) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, of.DefaultReason, result.Reason) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) +} + +func Test_ComparisonStrategy_FloatEvaluation(t *testing.T) { + successVal := float64(1234) + defaultVal := float64(0) + t.Run("single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + configureComparisonProvider(provider, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider", + Provider: provider, + }, + }, fallback) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "test-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("two success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, successVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider1, test-provider2", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, successVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, successVal, true, TestErrorNone) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider1, test-provider2, test-provider3", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple not found with single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider3", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("multiple not found with multiple success", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + provider4 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider4, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + { + Name: "test-provider4", + Provider: provider4, + }, + }, fallback) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Equal(t, "test-provider3, test-provider4", result.FlagMetadata[MetadataSuccessfulProviderName+"s"]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("comparison failure uses fallback", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(of.FloatResolutionDetail{ + Value: successVal, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.ResolutionError{}, + Variant: "on", + Reason: "", + FlagMetadata: make(of.FlagMetadata), + }, + }) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNone) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + }, fallback) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "fallback", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.True(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("comparison failure with not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(of.FloatResolutionDetail{ + Value: successVal, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.ResolutionError{}, + Variant: "on", + Reason: "", + FlagMetadata: make(of.FlagMetadata), + }, + }) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider3, successVal, true, TestErrorNone) + provider4 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider4, defaultVal, true, TestErrorNone) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + { + Name: "test-provider3", + Provider: provider3, + }, + { + Name: "test-provider4", + Provider: provider4, + }, + }, fallback) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "fallback", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.True(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("not found all providers", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, defaultVal, true, TestErrorNotFound) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorNotFound) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Contains(t, result.FlagMetadata, MetadataFallbackUsed) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) + + t.Run("non FLAG_NOT_FOUND error causes default", func(t *testing.T) { + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, successVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorError) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, of.DefaultReason, result.Reason) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) + }) +} + +func Test_ComparisonStrategy_ObjectEvaluation_AlwaysReturnsDefault(t *testing.T) { + successVal := struct{ Name string }{Name: "test"} + defaultVal := struct{}{} + ctrl := gomock.NewController(t) + fallback := mocks.NewMockFeatureProvider(ctrl) + fallback.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider1, successVal, true, TestErrorNone) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureComparisonProvider(provider2, defaultVal, true, TestErrorError) + + strategy := NewComparisonStrategy([]*NamedProvider{ + { + Name: "test-provider1", + Provider: provider1, + }, + { + Name: "test-provider2", + Provider: provider2, + }, + }, fallback) + + result := strategy.ObjectEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, of.DefaultReason, result.Reason) + assert.Equal(t, of.NewGeneralResolutionError(ErrAggregationNotAllowedText), result.ResolutionError) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyComparison, result.FlagMetadata[MetadataStrategyUsed]) + assert.NotContains(t, result.FlagMetadata, MetadataSuccessfulProviderName+"s") + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.False(t, result.FlagMetadata[MetadataFallbackUsed].(bool)) +} diff --git a/providers/multi-provider/pkg/strategies/first_match.go b/providers/multi-provider/pkg/strategies/first_match.go new file mode 100644 index 000000000..67602fffa --- /dev/null +++ b/providers/multi-provider/pkg/strategies/first_match.go @@ -0,0 +1,119 @@ +package strategies + +import ( + "context" + of "github.com/open-feature/go-sdk/openfeature" +) + +type FirstMatchStrategy struct { + providers []*NamedProvider +} + +var _ Strategy = (*FirstMatchStrategy)(nil) + +// NewFirstMatchStrategy Creates a new FirstMatchStrategy instance as a Strategy +func NewFirstMatchStrategy(providers []*NamedProvider) Strategy { + return &FirstMatchStrategy{providers: providers} +} + +func (f *FirstMatchStrategy) Name() EvaluationStrategy { + return StrategyFirstMatch +} + +func (f *FirstMatchStrategy) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.BoolResolutionDetail] { + r := p.Provider.BooleanEvaluation(c, flag, defaultValue, evalCtx) + return resultWrapper[of.BoolResolutionDetail]{ + result: &r, + name: p.Name, + value: r.Value, + detail: r.ProviderResolutionDetail, + } + } + result := evaluateFirstMatch[of.BoolResolutionDetail](ctx, f.providers, evalFunc, defaultValue) + result.result.ProviderResolutionDetail = result.detail + return *result.result +} + +func (f *FirstMatchStrategy) StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx of.FlattenedContext) of.StringResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.StringResolutionDetail] { + r := p.Provider.StringEvaluation(c, flag, defaultValue, evalCtx) + return resultWrapper[of.StringResolutionDetail]{ + result: &r, + name: p.Name, + value: r.Value, + detail: r.ProviderResolutionDetail, + } + } + result := evaluateFirstMatch[of.StringResolutionDetail](ctx, f.providers, evalFunc, defaultValue) + result.result.ProviderResolutionDetail = result.detail + return *result.result +} + +func (f *FirstMatchStrategy) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx of.FlattenedContext) of.FloatResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.FloatResolutionDetail] { + r := p.Provider.FloatEvaluation(c, flag, defaultValue, evalCtx) + return resultWrapper[of.FloatResolutionDetail]{ + result: &r, + name: p.Name, + value: r.Value, + detail: r.ProviderResolutionDetail, + } + } + result := evaluateFirstMatch[of.FloatResolutionDetail](ctx, f.providers, evalFunc, defaultValue) + result.result.ProviderResolutionDetail = result.detail + return *result.result +} + +func (f *FirstMatchStrategy) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx of.FlattenedContext) of.IntResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.IntResolutionDetail] { + r := p.Provider.IntEvaluation(c, flag, defaultValue, evalCtx) + return resultWrapper[of.IntResolutionDetail]{ + result: &r, + name: p.Name, + value: r.Value, + detail: r.ProviderResolutionDetail, + } + } + result := evaluateFirstMatch[of.IntResolutionDetail](ctx, f.providers, evalFunc, defaultValue) + result.result.ProviderResolutionDetail = result.detail + return *result.result +} + +func (f *FirstMatchStrategy) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.InterfaceResolutionDetail] { + r := p.Provider.ObjectEvaluation(c, flag, defaultValue, evalCtx) + return resultWrapper[of.InterfaceResolutionDetail]{ + result: &r, + name: p.Name, + value: r.Value, + detail: r.ProviderResolutionDetail, + } + } + result := evaluateFirstMatch[of.InterfaceResolutionDetail](ctx, f.providers, evalFunc, defaultValue) + result.result.ProviderResolutionDetail = result.detail + return *result.result +} + +func evaluateFirstMatch[R resultConstraint, DV bool | string | int64 | float64 | interface{}](ctx context.Context, providers []*NamedProvider, e evaluator[R], defaultVal DV) resultWrapper[R] { + for _, provider := range providers { + r := e(ctx, provider) + if r.detail.Error() != nil && r.detail.ResolutionDetail().ErrorCode == of.FlagNotFoundCode { + continue + } + if r.detail.Error() != nil { + r.detail.FlagMetadata = mergeFlagTags(r.detail.FlagMetadata, of.FlagMetadata{ + MetadataSuccessfulProviderName: "none", + MetadataStrategyUsed: StrategyFirstMatch, + }) + return r + } + + // success! + r.detail.FlagMetadata = setFlagMetadata(StrategyFirstMatch, provider.Name, r.detail.FlagMetadata) + return r + } + + // Build a default result if no matches are found + return buildDefaultResult[R](StrategyFirstMatch, defaultVal, nil) +} diff --git a/providers/multi-provider/pkg/strategies/first_match_test.go b/providers/multi-provider/pkg/strategies/first_match_test.go new file mode 100644 index 000000000..f52d99188 --- /dev/null +++ b/providers/multi-provider/pkg/strategies/first_match_test.go @@ -0,0 +1,480 @@ +package strategies + +import ( + "context" + "fmt" + m "github.com/open-feature/go-sdk-contrib/providers/multi-provider/internal/mocks" + of "github.com/open-feature/go-sdk/openfeature" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "testing" +) + +func createMockProviders(ctrl *gomock.Controller, count int) ([]*NamedProvider, map[string]*m.MockFeatureProvider) { + providers := make([]*NamedProvider, 0, count) + providerMocks := make(map[string]*m.MockFeatureProvider) + for index := range count { + provider := m.NewMockFeatureProvider(ctrl) + namedProvider := NamedProvider{ + Provider: provider, + Name: fmt.Sprintf("%d", index), + } + providerMocks[namedProvider.Name] = provider + providers = append(providers, &namedProvider) + } + + return providers, providerMocks +} + +func Test_FirstMatchStrategy_BooleanEvaluation(t *testing.T) { + t.Run("Single Provider Match", func(t *testing.T) { + ctrl := gomock.NewController(t) + providers, mocks := createMockProviders(ctrl, 1) + mocks[providers[0].Name].EXPECT(). + BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.BoolResolutionDetail{Value: true}) + + strategy := NewFirstMatchStrategy(providers) + result := strategy.BooleanEvaluation(context.Background(), "test-string", false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, providers[0].Name, result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("Default Resolution", func(t *testing.T) { + ctrl := gomock.NewController(t) + providers, mocks := createMockProviders(ctrl, 1) + mocks[providers[0].Name].EXPECT(). + BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.BoolResolutionDetail{ + Value: false, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewFlagNotFoundResolutionError("not found in any provider"), + }, + }) + strategy := NewFirstMatchStrategy(providers) + result := strategy.BooleanEvaluation(context.Background(), "test-string", false, of.FlattenedContext{}) + assert.False(t, result.Value) + assert.Equal(t, of.DefaultReason, result.Reason) + assert.Equal(t, of.NewFlagNotFoundResolutionError("not found in any provider").Error(), result.ResolutionError.Error()) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, StrategyFirstMatch, result.FlagMetadata[MetadataStrategyUsed]) + }) + + t.Run("Evaluation stops after match", func(t *testing.T) { + ctrl := gomock.NewController(t) + providers, mocks := createMockProviders(ctrl, 5) + mocks[providers[0].Name].EXPECT(). + BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.BoolResolutionDetail{ + Value: false, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewFlagNotFoundResolutionError("Flag not found"), + }, + }) + mocks[providers[1].Name].EXPECT(). + BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.BoolResolutionDetail{Value: true}) + for i, p := range providers { + if i <= 1 { + continue + } + mocks[p.Name].EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + } + + strategy := NewFirstMatchStrategy(providers) + result := strategy.BooleanEvaluation(context.Background(), "test-flag", false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, providers[1].Name, result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("Evaluation stops after first error that is not a FLAG_NOT_FOUND error", func(t *testing.T) { + ctrl := gomock.NewController(t) + providers, mocks := createMockProviders(ctrl, 5) + expectedErr := of.NewGeneralResolutionError("something went wrong") + mocks[providers[0].Name].EXPECT(). + BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.BoolResolutionDetail{ + Value: false, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: expectedErr, + Reason: of.ErrorReason, + }, + }) + for i, p := range providers { + if i == 0 { + continue + } + mocks[p.Name].EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + } + strategy := NewFirstMatchStrategy(providers) + result := strategy.BooleanEvaluation(context.Background(), "test-string", false, of.FlattenedContext{}) + assert.False(t, result.Value) + assert.Equal(t, of.ErrorReason, result.Reason) + assert.Equal(t, expectedErr.Error(), result.ResolutionError.Error()) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, StrategyFirstMatch, result.FlagMetadata[MetadataStrategyUsed]) + }) +} + +func Test_FirstMatchStrategy_StringEvaluation(t *testing.T) { + ctrl := gomock.NewController(t) + + t.Run("Single Provider Match", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 1) + mocks[providers[0].Name].EXPECT(). + StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.StringResolutionDetail{Value: "test"}) + + strategy := NewFirstMatchStrategy(providers) + result := strategy.StringEvaluation(context.Background(), "test-string", "", of.FlattenedContext{}) + assert.Equal(t, "test", result.Value) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, providers[0].Name, result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("Default Resolution", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 1) + mocks[providers[0].Name].EXPECT(). + StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.StringResolutionDetail{ + Value: "", + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewFlagNotFoundResolutionError("not found"), + }, + }) + strategy := NewFirstMatchStrategy(providers) + result := strategy.StringEvaluation(context.Background(), "test-string", "", of.FlattenedContext{}) + assert.Equal(t, "", result.Value) + assert.Equal(t, of.DefaultReason, result.Reason) + assert.Equal(t, of.NewFlagNotFoundResolutionError("not found in any provider").Error(), result.ResolutionError.Error()) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, StrategyFirstMatch, result.FlagMetadata[MetadataStrategyUsed]) + }) + + t.Run("Evaluation stops after match", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 5) + mocks[providers[0].Name].EXPECT(). + StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.StringResolutionDetail{ + Value: "", + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewFlagNotFoundResolutionError("Flag not found"), + }, + }) + mocks[providers[1].Name].EXPECT(). + StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.StringResolutionDetail{Value: "test"}) + for i, p := range providers { + if i <= 1 { + continue + } + mocks[p.Name].EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + } + + strategy := NewFirstMatchStrategy(providers) + result := strategy.StringEvaluation(context.Background(), "test-flag", "", of.FlattenedContext{}) + assert.Equal(t, "test", result.Value) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, providers[1].Name, result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("Evaluation stops after first error that is not a FLAG_NOT_FOUND error", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 5) + expectedErr := of.NewGeneralResolutionError("something went wrong") + mocks[providers[0].Name].EXPECT(). + StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.StringResolutionDetail{ + Value: "", + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: expectedErr, + Reason: of.ErrorReason, + }, + }) + for i, p := range providers { + if i == 0 { + continue + } + mocks[p.Name].EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + } + strategy := NewFirstMatchStrategy(providers) + result := strategy.StringEvaluation(context.Background(), "test-string", "", of.FlattenedContext{}) + assert.Equal(t, "", result.Value) + assert.Equal(t, of.ErrorReason, result.Reason) + assert.Equal(t, expectedErr.Error(), result.ResolutionError.Error()) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, StrategyFirstMatch, result.FlagMetadata[MetadataStrategyUsed]) + }) +} + +func Test_FirstMatchStrategy_IntEvaluation(t *testing.T) { + ctrl := gomock.NewController(t) + + t.Run("Single Provider Match", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 1) + mocks[providers[0].Name].EXPECT(). + IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.IntResolutionDetail{Value: 123}) + + strategy := NewFirstMatchStrategy(providers) + result := strategy.IntEvaluation(context.Background(), "test-string", 0, of.FlattenedContext{}) + assert.Equal(t, int64(123), result.Value) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, providers[0].Name, result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("Default Resolution", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 1) + mocks[providers[0].Name].EXPECT(). + IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.IntResolutionDetail{ + Value: 0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewFlagNotFoundResolutionError("not found"), + }, + }) + strategy := NewFirstMatchStrategy(providers) + result := strategy.IntEvaluation(context.Background(), "test-string", 0, of.FlattenedContext{}) + assert.Equal(t, int64(0), result.Value) + assert.Equal(t, of.DefaultReason, result.Reason) + assert.Equal(t, of.NewFlagNotFoundResolutionError("not found in any provider").Error(), result.ResolutionError.Error()) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, StrategyFirstMatch, result.FlagMetadata[MetadataStrategyUsed]) + }) + + t.Run("Evaluation stops after match", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 5) + mocks[providers[0].Name].EXPECT(). + IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.IntResolutionDetail{ + Value: 0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewFlagNotFoundResolutionError("Flag not found"), + }, + }) + mocks[providers[1].Name].EXPECT(). + IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.IntResolutionDetail{Value: 123}) + for i, p := range providers { + if i <= 1 { + continue + } + mocks[p.Name].EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + } + + strategy := NewFirstMatchStrategy(providers) + result := strategy.IntEvaluation(context.Background(), "test-flag", 0, of.FlattenedContext{}) + assert.Equal(t, int64(123), result.Value) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, providers[1].Name, result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("Evaluation stops after first error that is not a FLAG_NOT_FOUND error", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 5) + expectedErr := of.NewGeneralResolutionError("something went wrong") + mocks[providers[0].Name].EXPECT(). + IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.IntResolutionDetail{ + Value: 123, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: expectedErr, + Reason: of.ErrorReason, + }, + }) + for i, p := range providers { + if i == 0 { + continue + } + mocks[p.Name].EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + } + strategy := NewFirstMatchStrategy(providers) + result := strategy.IntEvaluation(context.Background(), "test-string", 123, of.FlattenedContext{}) + assert.Equal(t, int64(123), result.Value) + assert.Equal(t, of.ErrorReason, result.Reason) + assert.Equal(t, expectedErr.Error(), result.ResolutionError.Error()) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, StrategyFirstMatch, result.FlagMetadata[MetadataStrategyUsed]) + }) +} + +func Test_FirstMatchStrategy_FloatEvaluation(t *testing.T) { + ctrl := gomock.NewController(t) + + t.Run("Single Provider Match", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 1) + mocks[providers[0].Name].EXPECT(). + FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.FloatResolutionDetail{Value: 123}) + + strategy := NewFirstMatchStrategy(providers) + result := strategy.FloatEvaluation(context.Background(), "test-string", 0, of.FlattenedContext{}) + assert.Equal(t, float64(123), result.Value) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, providers[0].Name, result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("Default Resolution", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 1) + mocks[providers[0].Name].EXPECT(). + FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.FloatResolutionDetail{ + Value: 0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewFlagNotFoundResolutionError("not found"), + }, + }) + strategy := NewFirstMatchStrategy(providers) + result := strategy.FloatEvaluation(context.Background(), "test-string", 0, of.FlattenedContext{}) + assert.Equal(t, float64(0), result.Value) + assert.Equal(t, of.DefaultReason, result.Reason) + assert.Equal(t, of.NewFlagNotFoundResolutionError("not found in any provider").Error(), result.ResolutionError.Error()) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, StrategyFirstMatch, result.FlagMetadata[MetadataStrategyUsed]) + }) + + t.Run("Evaluation stops after match", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 5) + mocks[providers[0].Name].EXPECT(). + FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.FloatResolutionDetail{ + Value: 0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewFlagNotFoundResolutionError("Flag not found"), + }, + }) + mocks[providers[1].Name].EXPECT(). + FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.FloatResolutionDetail{Value: 123}) + for i, p := range providers { + if i <= 1 { + continue + } + mocks[p.Name].EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + } + + strategy := NewFirstMatchStrategy(providers) + result := strategy.FloatEvaluation(context.Background(), "test-flag", 0, of.FlattenedContext{}) + assert.Equal(t, float64(123), result.Value) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, providers[1].Name, result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("Evaluation stops after first error that is not a FLAG_NOT_FOUND error", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 5) + expectedErr := of.NewGeneralResolutionError("something went wrong") + mocks[providers[0].Name].EXPECT(). + FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.FloatResolutionDetail{ + Value: 123.0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: expectedErr, + Reason: of.ErrorReason, + }, + }) + for i, p := range providers { + if i == 0 { + continue + } + mocks[p.Name].EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + } + strategy := NewFirstMatchStrategy(providers) + result := strategy.FloatEvaluation(context.Background(), "test-string", 123, of.FlattenedContext{}) + assert.Equal(t, 123.0, result.Value) + assert.Equal(t, of.ErrorReason, result.Reason) + assert.Equal(t, expectedErr.Error(), result.ResolutionError.Error()) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, StrategyFirstMatch, result.FlagMetadata[MetadataStrategyUsed]) + }) +} + +func Test_FirstMatchStrategy_ObjectEvaluation(t *testing.T) { + ctrl := gomock.NewController(t) + + t.Run("Single Provider Match", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 1) + mocks[providers[0].Name].EXPECT(). + ObjectEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.InterfaceResolutionDetail{Value: struct{ Field int }{Field: 123}}) + + strategy := NewFirstMatchStrategy(providers) + result := strategy.ObjectEvaluation(context.Background(), "test-string", struct{}{}, of.FlattenedContext{}) + assert.Equal(t, struct{ Field int }{Field: 123}, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, providers[0].Name, result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("Default Resolution", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 1) + mocks[providers[0].Name].EXPECT(). + ObjectEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.InterfaceResolutionDetail{ + Value: struct{}{}, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewFlagNotFoundResolutionError("not found"), + Reason: of.DefaultReason, + }, + }) + strategy := NewFirstMatchStrategy(providers) + result := strategy.ObjectEvaluation(context.Background(), "test-string", struct{}{}, of.FlattenedContext{}) + assert.Equal(t, struct{}{}, result.Value) + assert.Equal(t, of.DefaultReason, result.Reason) + assert.Equal(t, of.NewFlagNotFoundResolutionError("not found in any provider").Error(), result.ResolutionError.Error()) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, StrategyFirstMatch, result.FlagMetadata[MetadataStrategyUsed]) + }) + + t.Run("Evaluation stops after match", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 5) + mocks[providers[0].Name].EXPECT(). + ObjectEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.InterfaceResolutionDetail{ + Value: 0, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewFlagNotFoundResolutionError("Flag not found"), + }, + }) + mocks[providers[1].Name].EXPECT(). + ObjectEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.InterfaceResolutionDetail{Value: struct{ Field int }{Field: 123}}) + for i, p := range providers { + if i <= 1 { + continue + } + mocks[p.Name].EXPECT().ObjectEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + } + + strategy := NewFirstMatchStrategy(providers) + result := strategy.ObjectEvaluation(context.Background(), "test-flag", struct{}{}, of.FlattenedContext{}) + assert.Equal(t, struct{ Field int }{Field: 123}, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, providers[1].Name, result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("Evaluation stops after first error that is not a FLAG_NOT_FOUND error", func(t *testing.T) { + providers, mocks := createMockProviders(ctrl, 5) + expectedErr := of.NewGeneralResolutionError("something went wrong") + mocks[providers[0].Name].EXPECT(). + ObjectEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(of.InterfaceResolutionDetail{ + Value: struct{}{}, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: expectedErr, + Reason: of.ErrorReason, + }, + }) + for i, p := range providers { + if i == 0 { + continue + } + mocks[p.Name].EXPECT().ObjectEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + } + strategy := NewFirstMatchStrategy(providers) + result := strategy.ObjectEvaluation(context.Background(), "test-string", struct{}{}, of.FlattenedContext{}) + assert.Equal(t, struct{}{}, result.Value) + assert.Equal(t, of.ErrorReason, result.Reason) + assert.Equal(t, expectedErr.Error(), result.ResolutionError.Error()) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, StrategyFirstMatch, result.FlagMetadata[MetadataStrategyUsed]) + }) +} diff --git a/providers/multi-provider/pkg/strategies/first_success.go b/providers/multi-provider/pkg/strategies/first_success.go new file mode 100644 index 000000000..10b4a2fbb --- /dev/null +++ b/providers/multi-provider/pkg/strategies/first_success.go @@ -0,0 +1,176 @@ +package strategies + +import ( + "context" + of "github.com/open-feature/go-sdk/openfeature" + "time" + + mperr "github.com/open-feature/go-sdk-contrib/providers/multi-provider/pkg/errors" +) + +type FirstSuccessStrategy struct { + providers []*NamedProvider + timeout time.Duration +} + +var _ Strategy = (*FirstSuccessStrategy)(nil) + +// NewFirstSuccessStrategy Creates a new FirstSuccessStrategy instance as a Strategy +func NewFirstSuccessStrategy(providers []*NamedProvider, timeout time.Duration) Strategy { + return &FirstSuccessStrategy{providers: providers, timeout: timeout} +} + +func (f *FirstSuccessStrategy) Name() EvaluationStrategy { + return StrategyFirstSuccess +} + +func (f *FirstSuccessStrategy) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.BoolResolutionDetail] { + result := p.Provider.BooleanEvaluation(c, flag, defaultValue, evalCtx) + return resultWrapper[of.BoolResolutionDetail]{ + result: &result, + name: p.Name, + value: result.Value, + detail: result.ProviderResolutionDetail, + } + } + result, metadata := evaluateFirstSuccess[of.BoolResolutionDetail](ctx, f.providers, evalFunc, defaultValue, f.timeout) + r := *result.result + r.ProviderResolutionDetail.FlagMetadata = mergeFlagTags(r.ProviderResolutionDetail.FlagMetadata, metadata) + return r +} + +func (f *FirstSuccessStrategy) StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx of.FlattenedContext) of.StringResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.StringResolutionDetail] { + result := p.Provider.StringEvaluation(c, flag, defaultValue, evalCtx) + return resultWrapper[of.StringResolutionDetail]{ + result: &result, + name: p.Name, + value: result.Value, + detail: result.ProviderResolutionDetail, + } + } + result, metadata := evaluateFirstSuccess[of.StringResolutionDetail](ctx, f.providers, evalFunc, defaultValue, f.timeout) + r := *result.result + r.ProviderResolutionDetail.FlagMetadata = mergeFlagTags(r.ProviderResolutionDetail.FlagMetadata, metadata) + return r +} + +func (f *FirstSuccessStrategy) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx of.FlattenedContext) of.FloatResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.FloatResolutionDetail] { + result := p.Provider.FloatEvaluation(c, flag, defaultValue, evalCtx) + return resultWrapper[of.FloatResolutionDetail]{ + result: &result, + name: p.Name, + value: result.Value, + detail: result.ProviderResolutionDetail, + } + } + result, metadata := evaluateFirstSuccess[of.FloatResolutionDetail](ctx, f.providers, evalFunc, defaultValue, f.timeout) + r := *result.result + r.ProviderResolutionDetail.FlagMetadata = mergeFlagTags(r.ProviderResolutionDetail.FlagMetadata, metadata) + return r +} + +func (f *FirstSuccessStrategy) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx of.FlattenedContext) of.IntResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.IntResolutionDetail] { + result := p.Provider.IntEvaluation(c, flag, defaultValue, evalCtx) + return resultWrapper[of.IntResolutionDetail]{ + result: &result, + name: p.Name, + value: result.Value, + detail: result.ProviderResolutionDetail, + } + } + result, metadata := evaluateFirstSuccess[of.IntResolutionDetail](ctx, f.providers, evalFunc, defaultValue, f.timeout) + r := *result.result + r.ProviderResolutionDetail.FlagMetadata = mergeFlagTags(r.ProviderResolutionDetail.FlagMetadata, metadata) + return r +} + +func (f *FirstSuccessStrategy) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail { + evalFunc := func(c context.Context, p *NamedProvider) resultWrapper[of.InterfaceResolutionDetail] { + result := p.Provider.ObjectEvaluation(c, flag, defaultValue, evalCtx) + return resultWrapper[of.InterfaceResolutionDetail]{ + result: &result, + name: p.Name, + value: result.Value, + detail: result.ProviderResolutionDetail, + } + } + result, metadata := evaluateFirstSuccess[of.InterfaceResolutionDetail](ctx, f.providers, evalFunc, defaultValue, f.timeout) + r := *result.result + r.ProviderResolutionDetail.FlagMetadata = mergeFlagTags(r.ProviderResolutionDetail.FlagMetadata, metadata) + return r +} + +func evaluateFirstSuccess[R resultConstraint, DV bool | string | int64 | float64 | interface{}](ctx context.Context, providers []*NamedProvider, e evaluator[R], defaultVal DV, timeout time.Duration) (resultWrapper[R], of.FlagMetadata) { + metadata := make(of.FlagMetadata) + metadata[MetadataStrategyUsed] = StrategyFirstSuccess + errChan := make(chan mperr.ProviderError, len(providers)) + notFoundChan := make(chan interface{}) + finishChan := make(chan *resultWrapper[R], len(providers)) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + for _, provider := range providers { + go func(c context.Context, p *NamedProvider) { + resultChan := make(chan *resultWrapper[R]) + go func() { + r := e(c, provider) + resultChan <- &r + }() + + select { + case <-c.Done(): + return + case r := <-resultChan: + if r.detail.Error() != nil && r.detail.ResolutionDetail().ErrorCode == of.FlagNotFoundCode { + notFoundChan <- struct{}{} + return + } else if r.detail.Error() != nil { + errChan <- mperr.ProviderError{ + Err: r.detail.ResolutionError, + ProviderName: p.Name, + } + return + } + finishChan <- r + } + + }(ctx, provider) + } + + errs := make([]mperr.ProviderError, 0, len(providers)) + notFoundCount := 0 + for { + if len(errs) == len(providers) { + err := mperr.NewAggregateError(errs) + r := buildDefaultResult[R](StrategyFirstSuccess, defaultVal, err) + return r, r.detail.FlagMetadata + } + + select { + case result := <-finishChan: + metadata[MetadataSuccessfulProviderName] = result.name + cancel() + return *result, metadata + case err := <-errChan: + errs = append(errs, err) + case <-notFoundChan: + notFoundCount += 1 + if notFoundCount == len(providers) { + r := buildDefaultResult[R](StrategyFirstSuccess, defaultVal, nil) + return r, r.detail.FlagMetadata + } + case <-ctx.Done(): + var err error + if len(errs) > 0 { + err = mperr.NewAggregateError(errs) + } else { + err = ctx.Err() + } + r := buildDefaultResult[R](StrategyFirstSuccess, defaultVal, err) + return r, r.detail.FlagMetadata + } + } +} diff --git a/providers/multi-provider/pkg/strategies/first_success_test.go b/providers/multi-provider/pkg/strategies/first_success_test.go new file mode 100644 index 000000000..3ca12742f --- /dev/null +++ b/providers/multi-provider/pkg/strategies/first_success_test.go @@ -0,0 +1,765 @@ +package strategies + +import ( + "context" + "github.com/open-feature/go-sdk-contrib/providers/multi-provider/internal/mocks" + of "github.com/open-feature/go-sdk/openfeature" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "testing" + "time" +) + +const TestFlag = "test-flag" + +func configureFirstSuccessProvider[R bool | int64 | float64 | string | interface{}](provider *mocks.MockFeatureProvider, resultVal R, state bool, error int, delay time.Duration) { + var rErr of.ResolutionError + var variant string + var reason of.Reason + switch error { + case TestErrorError: + rErr = of.NewGeneralResolutionError("test error") + reason = of.ErrorReason + case TestErrorNotFound: + rErr = of.NewFlagNotFoundResolutionError("test not found") + reason = of.DefaultReason + } + + if state { + variant = "on" + } else { + variant = "off" + } + details := of.ProviderResolutionDetail{ + ResolutionError: rErr, + Reason: reason, + Variant: variant, + FlagMetadata: make(of.FlagMetadata), + } + + switch any(resultVal).(type) { + case bool: + provider.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(c context.Context, flag string, defaultVal bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail { + time.Sleep(delay) + return of.BoolResolutionDetail{ + Value: any(resultVal).(bool), + ProviderResolutionDetail: details, + } + }).MaxTimes(1) + case string: + provider.EXPECT().StringEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(c context.Context, flag string, defaultVal string, evalCtx of.FlattenedContext) of.StringResolutionDetail { + time.Sleep(delay) + return of.StringResolutionDetail{ + Value: any(resultVal).(string), + ProviderResolutionDetail: details, + } + }).MaxTimes(1) + case int64: + provider.EXPECT().IntEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(c context.Context, flag string, defaultVal int64, evalCtx of.FlattenedContext) of.IntResolutionDetail { + time.Sleep(delay) + return of.IntResolutionDetail{ + Value: any(resultVal).(int64), + ProviderResolutionDetail: details, + } + }).MaxTimes(1) + case float64: + provider.EXPECT().FloatEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(c context.Context, flag string, defaultVal float64, evalCtx of.FlattenedContext) of.FloatResolutionDetail { + time.Sleep(delay) + return of.FloatResolutionDetail{ + Value: any(resultVal).(float64), + ProviderResolutionDetail: details, + } + }).MaxTimes(1) + default: + provider.EXPECT().ObjectEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(c context.Context, flag string, defaultVal any, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail { + time.Sleep(delay) + return of.InterfaceResolutionDetail{ + Value: resultVal, + ProviderResolutionDetail: details, + } + }).MaxTimes(1) + } +} + +func Test_FirstSuccessStrategy_BooleanEvaluation(t *testing.T) { + t.Run("single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider, true, true, TestErrorNone, 0*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "test-provider", + Provider: provider, + }, + }, 2*time.Second) + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "test-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("first success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, true, true, TestErrorNone, 5*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, false, false, TestErrorError, 50*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "success-provider", + Provider: provider1, + }, + { + Name: "failure-provider", + Provider: provider2, + }, + }, 2*time.Second) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "success-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("second success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, true, true, TestErrorNone, 500*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, false, false, TestErrorError, 5*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "success-provider", + Provider: provider1, + }, + { + Name: "failure-provider", + Provider: provider2, + }, + }, 2*time.Second) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.True(t, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "success-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("all errors (not including flag not found)", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, false, false, TestErrorError, 50*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, false, false, TestErrorError, 40*time.Millisecond) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider3, false, false, TestErrorError, 30*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "provider1", + Provider: provider1, + }, + { + Name: "provider2", + Provider: provider2, + }, + { + Name: "provider3", + Provider: provider3, + }, + }, 2*time.Second) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.False(t, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, of.ErrorReason, result.Reason) + }) + + t.Run("all not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, false, false, TestErrorNotFound, 50*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, false, false, TestErrorNotFound, 40*time.Millisecond) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider3, false, false, TestErrorNotFound, 30*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "provider1", + Provider: provider1, + }, + { + Name: "provider2", + Provider: provider2, + }, + { + Name: "provider3", + Provider: provider3, + }, + }, 2*time.Second) + + result := strategy.BooleanEvaluation(context.Background(), TestFlag, false, of.FlattenedContext{}) + assert.False(t, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, of.DefaultReason, result.Reason) + }) +} + +func Test_FirstSuccessStrategy_StringEvaluation(t *testing.T) { + successVal := "success" + defaultVal := "default" + t.Run("single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider, successVal, true, TestErrorNone, 0*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "test-provider", + Provider: provider, + }, + }, 2*time.Second) + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "test-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("first success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, successVal, true, TestErrorNone, 5*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 50*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "success-provider", + Provider: provider1, + }, + { + Name: "failure-provider", + Provider: provider2, + }, + }, 2*time.Second) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "success-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("second success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, successVal, true, TestErrorNone, 500*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 5*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "success-provider", + Provider: provider1, + }, + { + Name: "failure-provider", + Provider: provider2, + }, + }, 2*time.Second) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "success-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("all errors", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, defaultVal, false, TestErrorError, 50*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 40*time.Millisecond) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider3, defaultVal, false, TestErrorError, 30*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "provider1", + Provider: provider1, + }, + { + Name: "provider2", + Provider: provider2, + }, + { + Name: "provider3", + Provider: provider3, + }, + }, 2*time.Second) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, of.ErrorReason, result.Reason) + }) + + t.Run("all not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, defaultVal, false, TestErrorNotFound, 50*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorNotFound, 40*time.Millisecond) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider3, defaultVal, false, TestErrorNotFound, 30*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "provider1", + Provider: provider1, + }, + { + Name: "provider2", + Provider: provider2, + }, + { + Name: "provider3", + Provider: provider3, + }, + }, 2*time.Second) + + result := strategy.StringEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, of.DefaultReason, result.Reason) + }) +} + +func Test_FirstSuccessStrategy_IntEvaluation(t *testing.T) { + successVal := int64(150) + defaultVal := int64(0) + t.Run("single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider, successVal, true, TestErrorNone, 0*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "test-provider", + Provider: provider, + }, + }, 2*time.Second) + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "test-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("first success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, successVal, true, TestErrorNone, 5*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 50*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "success-provider", + Provider: provider1, + }, + { + Name: "failure-provider", + Provider: provider2, + }, + }, 2*time.Second) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "success-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("second success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, successVal, true, TestErrorNone, 500*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 5*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "success-provider", + Provider: provider1, + }, + { + Name: "failure-provider", + Provider: provider2, + }, + }, 2*time.Second) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "success-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("all errors", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, defaultVal, false, TestErrorError, 50*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 40*time.Millisecond) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider3, defaultVal, false, TestErrorError, 30*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "provider1", + Provider: provider1, + }, + { + Name: "provider2", + Provider: provider2, + }, + { + Name: "provider3", + Provider: provider3, + }, + }, 2*time.Second) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, of.ErrorReason, result.Reason) + }) + + t.Run("all not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, defaultVal, false, TestErrorNotFound, 50*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorNotFound, 40*time.Millisecond) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider3, defaultVal, false, TestErrorNotFound, 30*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "provider1", + Provider: provider1, + }, + { + Name: "provider2", + Provider: provider2, + }, + { + Name: "provider3", + Provider: provider3, + }, + }, 2*time.Second) + + result := strategy.IntEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, of.DefaultReason, result.Reason) + }) +} + +func Test_FirstSuccessStrategy_FloatEvaluation(t *testing.T) { + successVal := float64(15.5) + defaultVal := float64(0) + t.Run("single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider, successVal, true, TestErrorNone, 0*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "test-provider", + Provider: provider, + }, + }, 2*time.Second) + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "test-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("first success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, successVal, true, TestErrorNone, 5*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 50*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "success-provider", + Provider: provider1, + }, + { + Name: "failure-provider", + Provider: provider2, + }, + }, 2*time.Second) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "success-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("second success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, successVal, true, TestErrorNone, 500*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 5*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "success-provider", + Provider: provider1, + }, + { + Name: "failure-provider", + Provider: provider2, + }, + }, 2*time.Second) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "success-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("all errors", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, defaultVal, false, TestErrorError, 50*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 40*time.Millisecond) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider3, defaultVal, false, TestErrorError, 30*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "provider1", + Provider: provider1, + }, + { + Name: "provider2", + Provider: provider2, + }, + { + Name: "provider3", + Provider: provider3, + }, + }, 2*time.Second) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, of.ErrorReason, result.Reason) + }) + + t.Run("all not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, defaultVal, false, TestErrorNotFound, 50*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorNotFound, 40*time.Millisecond) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider3, defaultVal, false, TestErrorNotFound, 30*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "provider1", + Provider: provider1, + }, + { + Name: "provider2", + Provider: provider2, + }, + { + Name: "provider3", + Provider: provider3, + }, + }, 2*time.Second) + + result := strategy.FloatEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, of.DefaultReason, result.Reason) + }) +} + +func Test_FirstSuccessStrategy_ObjectEvaluation(t *testing.T) { + successVal := struct{ Field string }{Field: "test"} + defaultVal := struct{}{} + t.Run("single success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider, successVal, true, TestErrorNone, 0*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "test-provider", + Provider: provider, + }, + }, 2*time.Second) + result := strategy.ObjectEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Contains(t, result.FlagMetadata, MetadataStrategyUsed) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "test-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("first success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, successVal, true, TestErrorNone, 5*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 50*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "success-provider", + Provider: provider1, + }, + { + Name: "failure-provider", + Provider: provider2, + }, + }, 2*time.Second) + + result := strategy.ObjectEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "success-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("second success", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, successVal, true, TestErrorNone, 500*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 5*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "success-provider", + Provider: provider1, + }, + { + Name: "failure-provider", + Provider: provider2, + }, + }, 2*time.Second) + + result := strategy.ObjectEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, successVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "success-provider", result.FlagMetadata[MetadataSuccessfulProviderName]) + }) + + t.Run("all errors", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, defaultVal, false, TestErrorError, 50*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorError, 40*time.Millisecond) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider3, defaultVal, false, TestErrorError, 30*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "provider1", + Provider: provider1, + }, + { + Name: "provider2", + Provider: provider2, + }, + { + Name: "provider3", + Provider: provider3, + }, + }, 2*time.Second) + + result := strategy.ObjectEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, of.ErrorReason, result.Reason) + }) + + t.Run("all not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + provider1 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider1, defaultVal, false, TestErrorNotFound, 50*time.Millisecond) + provider2 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider2, defaultVal, false, TestErrorNotFound, 40*time.Millisecond) + provider3 := mocks.NewMockFeatureProvider(ctrl) + configureFirstSuccessProvider(provider3, defaultVal, false, TestErrorNotFound, 30*time.Millisecond) + + strategy := NewFirstSuccessStrategy([]*NamedProvider{ + { + Name: "provider1", + Provider: provider1, + }, + { + Name: "provider2", + Provider: provider2, + }, + { + Name: "provider3", + Provider: provider3, + }, + }, 2*time.Second) + + result := strategy.ObjectEvaluation(context.Background(), TestFlag, defaultVal, of.FlattenedContext{}) + assert.Equal(t, defaultVal, result.Value) + assert.Equal(t, StrategyFirstSuccess, result.FlagMetadata[MetadataStrategyUsed]) + assert.Contains(t, result.FlagMetadata, MetadataSuccessfulProviderName) + assert.Equal(t, "none", result.FlagMetadata[MetadataSuccessfulProviderName]) + assert.Equal(t, of.DefaultReason, result.Reason) + }) +} diff --git a/providers/multi-provider/pkg/strategies/strategies.go b/providers/multi-provider/pkg/strategies/strategies.go new file mode 100644 index 000000000..4670acfc6 --- /dev/null +++ b/providers/multi-provider/pkg/strategies/strategies.go @@ -0,0 +1,155 @@ +// Package strategies Resolution strategies are defined within this package +// +//go:generate go run go.uber.org/mock/mockgen -source=strategies.go -destination=../../pkg/strategies/strategy_mock.go -package=strategies +package strategies + +import ( + "context" + of "github.com/open-feature/go-sdk/openfeature" + "reflect" + "regexp" + "strings" +) + +const ( + MetadataSuccessfulProviderName = "multiprovider-successful-provider-name" + MetadataStrategyUsed = "multiprovider-strategy-used" + MetadataFallbackUsed = "multiprovider-fallback-used" + StrategyFirstMatch = "strategy-first-match" + StrategyFirstSuccess = "strategy-first-success" + StrategyComparison = "strategy-comparison" + ReasonAggregated of.Reason = "AGGREGATED" + ReasonAggregatedFallback of.Reason = "AGGREGATED_FALLBACK" + ErrAggregationNotAllowedText = "object evaluation not allowed for non-comparable types" +) + +type ( + // EvaluationStrategy Defines a strategy to use for resolving the result from multiple providers + EvaluationStrategy = string + // Strategy Interface for evaluating providers within the multi-provider. + Strategy interface { + Name() EvaluationStrategy + BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail + StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx of.FlattenedContext) of.StringResolutionDetail + FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx of.FlattenedContext) of.FloatResolutionDetail + IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx of.FlattenedContext) of.IntResolutionDetail + ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail + } + + // NamedProvider allows for a unique name to be assigned to a provider during a multi-provider set up. + // The name will be used when reporting errors & results to specify the provider associated. + NamedProvider struct { + Name string + Provider of.FeatureProvider + } + + resultConstraint interface { + of.BoolResolutionDetail | of.IntResolutionDetail | of.StringResolutionDetail | of.FloatResolutionDetail | of.InterfaceResolutionDetail + } + + resultWrapper[R resultConstraint] struct { + name string + result *R + value any + detail of.ProviderResolutionDetail + } + + evaluator[R resultConstraint] func(ctx context.Context, p *NamedProvider) resultWrapper[R] +) + +// buildDefaultResult Creates a default result using reflection via generics +func buildDefaultResult[R resultConstraint, DV bool | string | int64 | float64 | interface{}](strategy EvaluationStrategy, defaultValue DV, err error) resultWrapper[R] { + result := *new(R) + var rErr of.ResolutionError + var reason of.Reason + if err != nil { + rErr = of.NewGeneralResolutionError(cleanErrorMessage(err.Error())) + reason = of.ErrorReason + } else { + rErr = of.NewFlagNotFoundResolutionError("not found in any provider") + reason = of.DefaultReason + } + details := of.ProviderResolutionDetail{ + ResolutionError: rErr, + Reason: reason, + FlagMetadata: of.FlagMetadata{MetadataSuccessfulProviderName: "none", MetadataStrategyUsed: strategy}, + } + switch reflect.TypeOf(result).Name() { + case "BoolResolutionDetail": + r := any(result).(of.BoolResolutionDetail) + r.Value = any(defaultValue).(bool) + r.ProviderResolutionDetail = details + result = any(r).(R) + case "StringResolutionDetail": + r := any(result).(of.StringResolutionDetail) + r.Value = any(defaultValue).(string) + r.ProviderResolutionDetail = details + result = any(r).(R) + case "IntResolutionDetail": + r := any(result).(of.IntResolutionDetail) + r.Value = any(defaultValue).(int64) + r.ProviderResolutionDetail = details + result = any(r).(R) + case "FloatResolutionDetail": + r := any(result).(of.FloatResolutionDetail) + r.Value = any(defaultValue).(float64) + r.ProviderResolutionDetail = details + result = any(r).(R) + default: + r := any(result).(of.InterfaceResolutionDetail) + r.Value = defaultValue + r.ProviderResolutionDetail = details + result = any(r).(R) + } + + return resultWrapper[R]{result: &result, detail: details} +} + +func setFlagMetadata(strategyUsed EvaluationStrategy, successProviderName string, metadata of.FlagMetadata) of.FlagMetadata { + if metadata == nil { + metadata = make(of.FlagMetadata) + } + metadata[MetadataSuccessfulProviderName] = successProviderName + metadata[MetadataStrategyUsed] = strategyUsed + return metadata +} + +func cleanErrorMessage(msg string) string { + codeRegex := strings.Join([]string{ + string(of.ProviderNotReadyCode), + //string(of.ProviderFatalCode), // TODO: not available until go-sdk 14 + string(of.FlagNotFoundCode), + string(of.ParseErrorCode), + string(of.TypeMismatchCode), + string(of.TargetingKeyMissingCode), + string(of.GeneralCode), + }, "|") + re := regexp.MustCompile("(?:" + codeRegex + "): (.*)") + matches := re.FindSubmatch([]byte(msg)) + matchCount := len(matches) + switch matchCount { + case 0, 1: + return msg + default: + return strings.TrimSpace(string(matches[1])) + } +} + +// mergeFlagTags Merges flag metadata together into a single FlagMetadata instance by performing a shallow merge +func mergeFlagTags(tags ...of.FlagMetadata) of.FlagMetadata { + size := len(tags) + switch size { + case 0: + return make(of.FlagMetadata) + case 1: + return tags[0] + default: + merged := make(of.FlagMetadata) + for _, t := range tags { + for key, value := range t { + merged[key] = value + } + } + return merged + } +} diff --git a/providers/multi-provider/pkg/strategies/strategy_mock.go b/providers/multi-provider/pkg/strategies/strategy_mock.go new file mode 100644 index 000000000..2bf1ad1a5 --- /dev/null +++ b/providers/multi-provider/pkg/strategies/strategy_mock.go @@ -0,0 +1,126 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: strategies.go +// +// Generated by this command: +// +// mockgen -source=strategies.go -destination=../../pkg/strategies/strategy_mock.go -package=strategies +// + +// Package strategies is a generated GoMock package. +package strategies + +import ( + context "context" + reflect "reflect" + + openfeature "github.com/open-feature/go-sdk/openfeature" + gomock "go.uber.org/mock/gomock" +) + +// MockStrategy is a mock of Strategy interface. +type MockStrategy struct { + ctrl *gomock.Controller + recorder *MockStrategyMockRecorder + isgomock struct{} +} + +// MockStrategyMockRecorder is the mock recorder for MockStrategy. +type MockStrategyMockRecorder struct { + mock *MockStrategy +} + +// NewMockStrategy creates a new mock instance. +func NewMockStrategy(ctrl *gomock.Controller) *MockStrategy { + mock := &MockStrategy{ctrl: ctrl} + mock.recorder = &MockStrategyMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStrategy) EXPECT() *MockStrategyMockRecorder { + return m.recorder +} + +// BooleanEvaluation mocks base method. +func (m *MockStrategy) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BooleanEvaluation", ctx, flag, defaultValue, evalCtx) + ret0, _ := ret[0].(openfeature.BoolResolutionDetail) + return ret0 +} + +// BooleanEvaluation indicates an expected call of BooleanEvaluation. +func (mr *MockStrategyMockRecorder) BooleanEvaluation(ctx, flag, defaultValue, evalCtx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BooleanEvaluation", reflect.TypeOf((*MockStrategy)(nil).BooleanEvaluation), ctx, flag, defaultValue, evalCtx) +} + +// FloatEvaluation mocks base method. +func (m *MockStrategy) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FloatEvaluation", ctx, flag, defaultValue, evalCtx) + ret0, _ := ret[0].(openfeature.FloatResolutionDetail) + return ret0 +} + +// FloatEvaluation indicates an expected call of FloatEvaluation. +func (mr *MockStrategyMockRecorder) FloatEvaluation(ctx, flag, defaultValue, evalCtx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FloatEvaluation", reflect.TypeOf((*MockStrategy)(nil).FloatEvaluation), ctx, flag, defaultValue, evalCtx) +} + +// IntEvaluation mocks base method. +func (m *MockStrategy) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IntEvaluation", ctx, flag, defaultValue, evalCtx) + ret0, _ := ret[0].(openfeature.IntResolutionDetail) + return ret0 +} + +// IntEvaluation indicates an expected call of IntEvaluation. +func (mr *MockStrategyMockRecorder) IntEvaluation(ctx, flag, defaultValue, evalCtx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IntEvaluation", reflect.TypeOf((*MockStrategy)(nil).IntEvaluation), ctx, flag, defaultValue, evalCtx) +} + +// Name mocks base method. +func (m *MockStrategy) Name() EvaluationStrategy { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Name") + ret0, _ := ret[0].(EvaluationStrategy) + return ret0 +} + +// Name indicates an expected call of Name. +func (mr *MockStrategyMockRecorder) Name() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockStrategy)(nil).Name)) +} + +// ObjectEvaluation mocks base method. +func (m *MockStrategy) ObjectEvaluation(ctx context.Context, flag string, defaultValue any, evalCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ObjectEvaluation", ctx, flag, defaultValue, evalCtx) + ret0, _ := ret[0].(openfeature.InterfaceResolutionDetail) + return ret0 +} + +// ObjectEvaluation indicates an expected call of ObjectEvaluation. +func (mr *MockStrategyMockRecorder) ObjectEvaluation(ctx, flag, defaultValue, evalCtx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ObjectEvaluation", reflect.TypeOf((*MockStrategy)(nil).ObjectEvaluation), ctx, flag, defaultValue, evalCtx) +} + +// StringEvaluation mocks base method. +func (m *MockStrategy) StringEvaluation(ctx context.Context, flag, defaultValue string, evalCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StringEvaluation", ctx, flag, defaultValue, evalCtx) + ret0, _ := ret[0].(openfeature.StringResolutionDetail) + return ret0 +} + +// StringEvaluation indicates an expected call of StringEvaluation. +func (mr *MockStrategyMockRecorder) StringEvaluation(ctx, flag, defaultValue, evalCtx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StringEvaluation", reflect.TypeOf((*MockStrategy)(nil).StringEvaluation), ctx, flag, defaultValue, evalCtx) +} diff --git a/release-please-config.json b/release-please-config.json index ee5b1b009..d1b4c7301 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -139,6 +139,14 @@ "bump-patch-for-minor-pre-major": true, "versioning": "default", "extra-files": [] + }, + "providers/multi-provider": { + "release-type": "go", + "package-name": "providers/multi-provider", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [] } }, "changelog-sections": [