From 3207242d65fe2bab5611d4c4ebc89ef17a13427f Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sat, 3 May 2025 17:47:14 -0400 Subject: [PATCH 01/16] refactor: Change option availability - remove event publishing toggle options, this will always be enabled now - update options for logging Signed-off-by: Jordan Blacker --- providers/multi-provider/pkg/options.go | 29 +++++++++++++------------ 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/providers/multi-provider/pkg/options.go b/providers/multi-provider/pkg/options.go index bbc38154b..b48336a7b 100644 --- a/providers/multi-provider/pkg/options.go +++ b/providers/multi-provider/pkg/options.go @@ -14,6 +14,21 @@ func WithLogger(l *slog.Logger) Option { } } +// WithLoggerDefault Uses the default [slog.Logger] (this is the default setting) +// use WithoutLogging to disable logging completely +func WithLoggerDefault() Option { + return func(conf *Configuration) { + conf.logger = slog.Default() + } +} + +// WithoutLogging Disables logging functionality +func WithoutLogging() Option { + return func(conf *Configuration) { + conf.logger = nil + } +} + // WithTimeout Set a timeout for the total runtime for evaluation of parallel strategies func WithTimeout(d time.Duration) Option { return func(conf *Configuration) { @@ -35,17 +50,3 @@ func WithCustomStrategy(s strategies.Strategy) Option { 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 - } -} From 28a343e837464995ac66a3d99ab5f785f39f5eb0 Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sat, 3 May 2025 17:48:01 -0400 Subject: [PATCH 02/16] feat: Add ConditionalLogger type This allows for always calling the logger, but can act like a no-op depending on the config Signed-off-by: Jordan Blacker --- .../multi-provider/internal/logger/logger.go | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 providers/multi-provider/internal/logger/logger.go diff --git a/providers/multi-provider/internal/logger/logger.go b/providers/multi-provider/internal/logger/logger.go new file mode 100644 index 000000000..88c6a8888 --- /dev/null +++ b/providers/multi-provider/internal/logger/logger.go @@ -0,0 +1,62 @@ +package logger + +import ( + "context" + "log/slog" +) + +// ConditionalLogger Logger instance that may be empty so the caller does not need to worry about checking +// if logging is enabled or not. This type should be treated as immutable +type ConditionalLogger struct { + l *slog.Logger +} + +// NewConditionalLogger Creates a new ConditionalLogger. If a nil value is provided no logging will be performed and all +// methods will act as no-ops. The state of a ConditionalLogger should be treated as immutable +func NewConditionalLogger(l *slog.Logger) *ConditionalLogger { + return &ConditionalLogger{l} +} + +// enabled Checks to determine if logging should be performed. Also acts as an internal nil check +func (cl *ConditionalLogger) enabled() bool { + return cl.l != nil +} + +// LogError Log a message at the error level +func (cl *ConditionalLogger) LogError(ctx context.Context, msg string, attr ...slog.Attr) { + if cl.enabled() { + cl.l.LogAttrs(ctx, slog.LevelError, msg, attr...) + } +} + +// LogWarn Log a message at the warn level +func (cl *ConditionalLogger) LogWarn(ctx context.Context, msg string, attr ...slog.Attr) { + if cl.enabled() { + cl.l.LogAttrs(ctx, slog.LevelWarn, msg, attr...) + } +} + +// LogInfo Log a message at the info level (should be used sparingly) +func (cl *ConditionalLogger) LogInfo(ctx context.Context, msg string, attr ...slog.Attr) { + if cl.enabled() { + cl.l.LogAttrs(ctx, slog.LevelInfo, msg, attr...) + } +} + +// LogDebug Log a message at the debug level +func (cl *ConditionalLogger) LogDebug(ctx context.Context, msg string, attr ...slog.Attr) { + if cl.enabled() { + cl.l.LogAttrs(ctx, slog.LevelDebug, msg, attr...) + } +} + +// With Creates and returns a child logger with the provided attributes set. If the current logger is disabled by having +// the same disabled logger will be returned and this acts as a no-op. +func (cl *ConditionalLogger) With(attr ...any) *ConditionalLogger { + if cl.enabled() { + return &ConditionalLogger{l: cl.l.With(attr...)} + } + + // Don't bother creating a child logger since there's no difference + return cl +} From 43a1fd752c53998af5923ba910c650686043e1dc Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sat, 3 May 2025 17:50:02 -0400 Subject: [PATCH 03/16] feat: Add eventing functionality Added a ton of logging for testing purposes at the debug level Implemented the eventing based on the multi-provider specification in the specification's appendix. This also now includes state change handling Signed-off-by: Jordan Blacker --- providers/multi-provider/pkg/providers.go | 283 +++++++++++++++--- .../multi-provider/pkg/providers_test.go | 103 ++++++- 2 files changed, 337 insertions(+), 49 deletions(-) diff --git a/providers/multi-provider/pkg/providers.go b/providers/multi-provider/pkg/providers.go index c40703981..75e9f9f54 100644 --- a/providers/multi-provider/pkg/providers.go +++ b/providers/multi-provider/pkg/providers.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/open-feature/go-sdk-contrib/providers/multi-provider/internal/logger" "github.com/open-feature/go-sdk-contrib/providers/multi-provider/pkg/strategies" "golang.org/x/sync/errgroup" "log/slog" @@ -20,13 +21,19 @@ import ( 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 + providers ProviderMap + metadata of.Metadata + status of.State + totalStatusLock sync.RWMutex + strategy strategies.Strategy + logger *logger.ConditionalLogger + outboundEvents chan of.Event + inboundEvents chan namedEvent + workerGroup sync.WaitGroup + shutdownFunc context.CancelFunc + providerStatusLock sync.Mutex + providerStatus map[string]of.State + initialized bool } // Configuration MultiProvider's internal configuration @@ -47,6 +54,12 @@ type ( ProviderMap map[string]of.FeatureProvider // Option Function used for setting Configuration via the options pattern Option func(*Configuration) + + // Private Types + namedEvent struct { + of.Event + providerName string + } ) const ( @@ -61,10 +74,42 @@ const ( 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" + StrategyCustom EvaluationStrategy = "strategy-custom" + MetadataProviderName = "multiprovider-provider-name" + MetadataProviderType = "multiprovider-provider-type" + MetadataInternalError = "multiprovider-internal-error" ) -var _ of.FeatureProvider = (*MultiProvider)(nil) +var ( + _ of.FeatureProvider = (*MultiProvider)(nil) + _ of.EventHandler = (*MultiProvider)(nil) + _ of.StateHandler = (*MultiProvider)(nil) + stateValues map[of.State]int + stateTable [3]of.State + eventTypeToState map[of.EventType]of.State +) + +func init() { + // used for mapping provider event types & provider states to comparable values for evaluation + stateValues = map[of.State]int{ + "": -1, // Not a real state, but used for handling provider config changes + of.ErrorState: 0, + of.StaleState: 1, + of.ReadyState: 2, + } + // used for mapping + stateTable = [3]of.State{ + of.ReadyState, // 0 + of.StaleState, // 1 + of.ErrorState, // 2 + } + eventTypeToState = map[of.EventType]of.State{ + of.ProviderConfigChange: "", + of.ProviderReady: of.ReadyState, + of.ProviderStale: of.StaleState, + of.ProviderError: of.ErrorState, + } +} // AsNamedProviderSlice Converts the map into a slice of NamedProvider instances func (m ProviderMap) AsNamedProviderSlice() []*strategies.NamedProvider { @@ -115,28 +160,20 @@ func NewMultiProvider(providerMap ProviderMap, evaluationStrategy EvaluationStra } config := &Configuration{ - logger: slog.Default(), + logger: slog.Default(), // Logging enabled by default using default slog logger } 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(), + providers: providerMap, + outboundEvents: make(chan of.Event), + logger: logger.NewConditionalLogger(config.logger), + metadata: providerMap.buildMetadata(), + status: of.NotReadyState, + providerStatus: make(map[string]of.State), } var zeroDuration time.Duration @@ -220,47 +257,184 @@ func (mp *MultiProvider) ObjectEvaluation(ctx context.Context, flag string, defa // 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 - + // wrapper type used only for initialization of event listener workers + type namedEventHandler struct { + of.EventHandler + name string + } + mp.logger.LogDebug(context.Background(), "start initialization") + mp.inboundEvents = make(chan namedEvent, len(mp.providers)) + handlers := make(chan namedEventHandler) for name, provider := range mp.providers { + // Initialize each provider to not ready state. No locks required there are no workers running + mp.providerStatus[name] = of.NotReadyState + l := mp.logger.With(slog.String("multiprovider-provider-name", name)) + eg.Go(func() error { + l.LogDebug(context.Background(), "starting initialization") stateHandle, ok := provider.(of.StateHandler) if !ok { - return nil - } - if err := stateHandle.Init(evalCtx); err != nil { + l.LogDebug(context.Background(), "StateHandle not implemented, skipping initialization") + } else if err := stateHandle.Init(evalCtx); err != nil { + l.LogError(context.Background(), "initialization failed", slog.Any("error", err)) return &mperr.ProviderError{ Err: err, ProviderName: name, } } - + l.LogDebug(context.Background(), "initialization successful") + if eventer, ok := provider.(of.EventHandler); ok { + l.LogDebug(context.Background(), "detected EventHandler implementation") + handlers <- namedEventHandler{eventer, name} + } else { + // Do not yet update providers that need event handling + mp.providerStatusLock.Lock() + defer mp.providerStatusLock.Unlock() + mp.providerStatus[name] = of.ReadyState + } return nil }) } if err := eg.Wait(); err != nil { - mp.mu.Lock() - defer mp.mu.Unlock() - mp.status = of.ErrorState - + mp.setStatus(of.ErrorState) + var pErr *mperr.ProviderError + if errors.As(err, &pErr) { + // Update provider status to error, no event needs to be emitted. + // No locks needed as no workers are active at this point + mp.providerStatus[pErr.ProviderName] = of.ErrorState + } else { + pErr = &mperr.ProviderError{ + Err: err, + ProviderName: "unknown", + } + } + mp.outboundEvents <- of.Event{ + ProviderName: mp.Metadata().Name, + EventType: of.ProviderError, + ProviderEventDetails: of.ProviderEventDetails{ + Message: fmt.Sprintf("internal provider %s encountered an error during initialization: %+v", pErr.ProviderName, pErr.Err), + FlagChanges: nil, + EventMetadata: map[string]interface{}{ + MetadataProviderName: pErr.ProviderName, + MetadataInternalError: pErr.Error(), + }, + }, + } return err } - - mp.mu.Lock() - defer mp.mu.Unlock() - mp.status = of.ReadyState + close(handlers) + workerCtx, shutdownFunc := context.WithCancel(context.Background()) + for h := range handlers { + go mp.startListening(workerCtx, h.name, h.EventHandler, &mp.workerGroup) + } + mp.shutdownFunc = shutdownFunc + + go func() { + workerLogger := mp.logger.With(slog.String("multiprovider-worker", "event-forwarder-worker")) + mp.workerGroup.Add(1) + defer mp.workerGroup.Done() + for e := range mp.inboundEvents { + l := workerLogger.With( + slog.String("multiprovider-provider-name", e.providerName), + slog.String("multiprovider-provider-type", e.ProviderName), + ) + l.LogDebug(context.Background(), fmt.Sprintf("received %s event from provider", e.EventType)) + state := mp.updateProviderStateAndEvaluateTotalState(e, l) + if state != mp.Status() { + mp.setStatus(state) + mp.outboundEvents <- e.Event + l.LogDebug(context.Background(), "forwarded state update event") + } else { + l.LogDebug(context.Background(), "total state not updated, inbound event will not be emitted") + } + } + }() + + mp.setStatus(of.ReadyState) + mp.outboundEvents <- of.Event{ + ProviderName: mp.Metadata().Name, + EventType: of.ProviderReady, + ProviderEventDetails: of.ProviderEventDetails{ + Message: "all internal providers initialized successfully", + FlagChanges: nil, + EventMetadata: map[string]interface{}{ + MetadataProviderName: "all", + }, + }, + } + mp.initialized = true 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 +// startListening is intended to be +func (mp *MultiProvider) startListening(ctx context.Context, name string, h of.EventHandler, wg *sync.WaitGroup) { + wg.Add(1) + defer wg.Done() + for { + select { + case e := <-h.EventChannel(): + e.EventMetadata[MetadataProviderName] = name + e.EventMetadata[MetadataProviderType] = h.(of.FeatureProvider).Metadata().Name + mp.inboundEvents <- namedEvent{ + Event: e, + providerName: name, + } + case <-ctx.Done(): + return + } + } +} + +func (mp *MultiProvider) updateProviderStateAndEvaluateTotalState(e namedEvent, l *logger.ConditionalLogger) of.State { + if e.EventType == of.ProviderConfigChange { + l.LogDebug(context.Background(), fmt.Sprintf("ProviderConfigChange event: %s", e.Message)) + return mp.Status() + } + mp.providerStatusLock.Lock() + defer mp.providerStatusLock.Unlock() + logProviderState(l, e, mp.providerStatus[e.providerName]) + mp.providerStatus[e.providerName] = eventTypeToState[e.EventType] + maxState := stateValues[of.ReadyState] // initialize to the lowest state value + for _, s := range mp.providerStatus { + if stateValues[s] > maxState { + // change in state due to higher priority + maxState = stateValues[s] + } + } + return stateTable[maxState] +} + +func logProviderState(l *logger.ConditionalLogger, e namedEvent, previousState of.State) { + switch eventTypeToState[e.EventType] { + case of.ReadyState: + if previousState != of.NotReadyState { + l.LogInfo(context.Background(), fmt.Sprintf("provider %s has returned to ready state from %s", e.providerName, previousState)) + return + } + l.LogDebug(context.Background(), fmt.Sprintf("provider %s is ready", e.providerName)) + case of.StaleState: + l.LogWarn(context.Background(), fmt.Sprintf("provider %s is stale: %s", e.providerName, e.Message)) + case of.ErrorState: + l.LogError(context.Background(), fmt.Sprintf("provider %s is in an error state: %s", e.providerName, e.Message)) + } } // Shutdown Shuts down all internal providers func (mp *MultiProvider) Shutdown() { + if !mp.initialized { + // Don't do anything if we were never initialized + return + } + // Stop all event listener workers, shutdown events should not affect overall state + mp.shutdownFunc() + // Stop forwarding worker + close(mp.inboundEvents) + mp.logger.LogDebug(context.Background(), "triggered worker shutdown") + // Wait for workers to stop + mp.workerGroup.Wait() + mp.logger.LogDebug(context.Background(), "worker shutdown completed") + mp.logger.LogDebug(context.Background(), "starting provider shutdown") var wg sync.WaitGroup for _, provider := range mp.providers { wg.Add(1) @@ -272,10 +446,31 @@ func (mp *MultiProvider) Shutdown() { }(provider) } + mp.logger.LogDebug(context.Background(), "waiting for provider shutdown completion") wg.Wait() + mp.logger.LogDebug(context.Background(), "provider shutdown completed") + mp.setStatus(of.NotReadyState) + close(mp.outboundEvents) + mp.outboundEvents = nil + mp.inboundEvents = nil + mp.initialized = false +} + +// Status the current status of the MultiProvider +func (mp *MultiProvider) Status() of.State { + mp.totalStatusLock.RLock() + defer mp.totalStatusLock.RUnlock() + return mp.status +} + +func (mp *MultiProvider) setStatus(state of.State) { + mp.totalStatusLock.Lock() + defer mp.totalStatusLock.Unlock() + mp.status = state + mp.logger.LogDebug(context.Background(), "state updated", slog.String("state", string(state))) } -// EventChannel the channel events are emitted on (Not Yet Implemented) +// EventChannel the channel events are emitted on func (mp *MultiProvider) EventChannel() <-chan of.Event { - return mp.events + return mp.outboundEvents } diff --git a/providers/multi-provider/pkg/providers_test.go b/providers/multi-provider/pkg/providers_test.go index d4efdf02a..a12895b00 100644 --- a/providers/multi-provider/pkg/providers_test.go +++ b/providers/multi-provider/pkg/providers_test.go @@ -1,6 +1,7 @@ package multiprovider import ( + "context" "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" @@ -147,10 +148,34 @@ func TestMultiProvider_Init(t *testing.T) { "foo": "bar", } evalCtx := openfeature.NewTargetlessEvaluationContext(attributes) - + eventChan := make(chan of.Event) + ctx, cancel := context.WithCancel(t.Context()) + go func() { + select { + case e := <-mp.EventChannel(): + eventChan <- e + case <-ctx.Done(): + return + } + }() err = mp.Init(evalCtx) require.NoError(t, err) - assert.Equal(t, of.ReadyState, mp.status) + assert.Equal(t, of.ReadyState, mp.Status()) + cancel() + event := <-eventChan + assert.NotZero(t, event) + assert.Equal(t, mp.Metadata().Name, event.ProviderName) + assert.Equal(t, of.ProviderReady, event.EventType) + assert.Equal(t, of.ProviderEventDetails{ + Message: "all internal providers initialized successfully", + FlagChanges: nil, + EventMetadata: map[string]interface{}{ + MetadataProviderName: "all", + }, + }, event.ProviderEventDetails) + t.Cleanup(func() { + mp.Shutdown() + }) } func TestMultiProvider_InitErrorWithProvider(t *testing.T) { @@ -183,13 +208,35 @@ func TestMultiProvider_InitErrorWithProvider(t *testing.T) { "foo": "bar", } evalCtx := openfeature.NewTargetlessEvaluationContext(attributes) - + eventChan := make(chan of.Event) + ctx, cancel := context.WithCancel(t.Context()) + go func() { + select { + case e := <-mp.EventChannel(): + eventChan <- e + case <-ctx.Done(): + return + } + }() err = mp.Init(evalCtx) - require.Errorf(t, err, "Provider provider1: test error") + require.Errorf(t, err, "Provider provider3: test error") assert.Equal(t, of.ErrorState, mp.status) + cancel() + event := <-eventChan + assert.NotZero(t, event) + assert.Equal(t, mp.Metadata().Name, event.ProviderName) + assert.Equal(t, of.ProviderError, event.EventType) + assert.Equal(t, of.ProviderEventDetails{ + Message: "internal provider provider3 encountered an error during initialization: test error", + FlagChanges: nil, + EventMetadata: map[string]interface{}{ + MetadataProviderName: "provider3", + MetadataInternalError: "Provider provider3: test error", + }, + }, event.ProviderEventDetails) } -func TestMultiProvider_Shutdown(t *testing.T) { +func TestMultiProvider_Shutdown_WithoutInit(t *testing.T) { ctrl := gomock.NewController(t) testProvider1 := mocks.NewMockFeatureProvider(ctrl) @@ -207,3 +254,49 @@ func TestMultiProvider_Shutdown(t *testing.T) { mp.Shutdown() } + +func TestMultiProvider_Shutdown_WithInit(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{}) + handlingProvider := mocks.NewMockFeatureProvider(ctrl) + handlingProvider.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + handledHandler := mocks.NewMockStateHandler(ctrl) + handledHandler.EXPECT().Init(gomock.Any()).Return(nil) + handledHandler.EXPECT().Shutdown() + testProvider3 := struct { + of.FeatureProvider + of.StateHandler + }{ + handlingProvider, + handledHandler, + } + + providers := make(ProviderMap) + providers["provider1"] = testProvider1 + providers["provider2"] = testProvider2 + providers["provider3"] = testProvider3 + mp, err := NewMultiProvider(providers, strategies.StrategyFirstMatch) + require.NoError(t, err) + evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ + "foo": "bar", + }) + eventChan := make(chan of.Event) + ctx, cancel := context.WithCancel(t.Context()) + go func() { + select { + case e := <-mp.EventChannel(): + eventChan <- e + case <-ctx.Done(): + return + } + }() + err = mp.Init(evalCtx) + require.NoError(t, err) + assert.Equal(t, of.ReadyState, mp.Status()) + cancel() + mp.Shutdown() + assert.Equal(t, of.NotReadyState, mp.Status()) +} From 04525cea84dec448f94580ebaebc8f245ad6284d Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sun, 4 May 2025 08:54:16 -0400 Subject: [PATCH 04/16] refactor: Rename status -> totalStatus for consistency Signed-off-by: Jordan Blacker --- providers/multi-provider/pkg/providers.go | 13 ++++++++----- providers/multi-provider/pkg/providers_test.go | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/providers/multi-provider/pkg/providers.go b/providers/multi-provider/pkg/providers.go index 75e9f9f54..b82c52fa8 100644 --- a/providers/multi-provider/pkg/providers.go +++ b/providers/multi-provider/pkg/providers.go @@ -23,8 +23,11 @@ type ( MultiProvider struct { providers ProviderMap metadata of.Metadata - status of.State + initialized bool + totalStatus of.State totalStatusLock sync.RWMutex + providerStatus map[string]of.State + providerStatusLock sync.Mutex strategy strategies.Strategy logger *logger.ConditionalLogger outboundEvents chan of.Event @@ -172,7 +175,7 @@ func NewMultiProvider(providerMap ProviderMap, evaluationStrategy EvaluationStra outboundEvents: make(chan of.Event), logger: logger.NewConditionalLogger(config.logger), metadata: providerMap.buildMetadata(), - status: of.NotReadyState, + totalStatus: of.NotReadyState, providerStatus: make(map[string]of.State), } @@ -456,17 +459,17 @@ func (mp *MultiProvider) Shutdown() { mp.initialized = false } -// Status the current status of the MultiProvider +// Status the current state of the MultiProvider func (mp *MultiProvider) Status() of.State { mp.totalStatusLock.RLock() defer mp.totalStatusLock.RUnlock() - return mp.status + return mp.totalStatus } func (mp *MultiProvider) setStatus(state of.State) { mp.totalStatusLock.Lock() defer mp.totalStatusLock.Unlock() - mp.status = state + mp.totalStatus = state mp.logger.LogDebug(context.Background(), "state updated", slog.String("state", string(state))) } diff --git a/providers/multi-provider/pkg/providers_test.go b/providers/multi-provider/pkg/providers_test.go index a12895b00..c9ed2896a 100644 --- a/providers/multi-provider/pkg/providers_test.go +++ b/providers/multi-provider/pkg/providers_test.go @@ -220,7 +220,7 @@ func TestMultiProvider_InitErrorWithProvider(t *testing.T) { }() err = mp.Init(evalCtx) require.Errorf(t, err, "Provider provider3: test error") - assert.Equal(t, of.ErrorState, mp.status) + assert.Equal(t, of.ErrorState, mp.totalStatus) cancel() event := <-eventChan assert.NotZero(t, event) From 59aa29d973d2839488e131aaba49b4c904a2091d Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sun, 4 May 2025 15:49:58 -0400 Subject: [PATCH 05/16] feat: Add hook mock Updated makefile to have this automatically generated Signed-off-by: Jordan Blacker --- providers/multi-provider/Makefile | 5 + .../internal/mocks/hook_mock.go | 95 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 providers/multi-provider/internal/mocks/hook_mock.go diff --git a/providers/multi-provider/Makefile b/providers/multi-provider/Makefile index 941e647cb..29ec5bf81 100644 --- a/providers/multi-provider/Makefile +++ b/providers/multi-provider/Makefile @@ -5,6 +5,11 @@ 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 + mockgen -source=${GOPATH}/pkg/mod/github.com/open-feature/go-sdk@v1.13.1/openfeature/hooks.go -package=mocks -destination=./internal/mocks/hook_mock.go + +lint: + go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.5 + ${GOPATH}/bin/golangci-lint run ./... test: go test ./... \ No newline at end of file diff --git a/providers/multi-provider/internal/mocks/hook_mock.go b/providers/multi-provider/internal/mocks/hook_mock.go new file mode 100644 index 000000000..87ae8f93e --- /dev/null +++ b/providers/multi-provider/internal/mocks/hook_mock.go @@ -0,0 +1,95 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: /Users/jordanblacker/go/pkg/mod/github.com/open-feature/go-sdk@v1.13.1/openfeature/hooks.go +// +// Generated by this command: +// +// mockgen -source=/Users/jordanblacker/go/pkg/mod/github.com/open-feature/go-sdk@v1.13.1/openfeature/hooks.go -package=mocks -destination=./internal/mocks/hook_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" +) + +// MockHook is a mock of Hook interface. +type MockHook struct { + ctrl *gomock.Controller + recorder *MockHookMockRecorder + isgomock struct{} +} + +// MockHookMockRecorder is the mock recorder for MockHook. +type MockHookMockRecorder struct { + mock *MockHook +} + +// NewMockHook creates a new mock instance. +func NewMockHook(ctrl *gomock.Controller) *MockHook { + mock := &MockHook{ctrl: ctrl} + mock.recorder = &MockHookMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHook) EXPECT() *MockHookMockRecorder { + return m.recorder +} + +// After mocks base method. +func (m *MockHook) After(ctx context.Context, hookContext openfeature.HookContext, flagEvaluationDetails openfeature.InterfaceEvaluationDetails, hookHints openfeature.HookHints) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "After", ctx, hookContext, flagEvaluationDetails, hookHints) + ret0, _ := ret[0].(error) + return ret0 +} + +// After indicates an expected call of After. +func (mr *MockHookMockRecorder) After(ctx, hookContext, flagEvaluationDetails, hookHints any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "After", reflect.TypeOf((*MockHook)(nil).After), ctx, hookContext, flagEvaluationDetails, hookHints) +} + +// Before mocks base method. +func (m *MockHook) Before(ctx context.Context, hookContext openfeature.HookContext, hookHints openfeature.HookHints) (*openfeature.EvaluationContext, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Before", ctx, hookContext, hookHints) + ret0, _ := ret[0].(*openfeature.EvaluationContext) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Before indicates an expected call of Before. +func (mr *MockHookMockRecorder) Before(ctx, hookContext, hookHints any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Before", reflect.TypeOf((*MockHook)(nil).Before), ctx, hookContext, hookHints) +} + +// Error mocks base method. +func (m *MockHook) Error(ctx context.Context, hookContext openfeature.HookContext, err error, hookHints openfeature.HookHints) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Error", ctx, hookContext, err, hookHints) +} + +// Error indicates an expected call of Error. +func (mr *MockHookMockRecorder) Error(ctx, hookContext, err, hookHints any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockHook)(nil).Error), ctx, hookContext, err, hookHints) +} + +// Finally mocks base method. +func (m *MockHook) Finally(ctx context.Context, hookContext openfeature.HookContext, hookHints openfeature.HookHints) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Finally", ctx, hookContext, hookHints) +} + +// Finally indicates an expected call of Finally. +func (mr *MockHookMockRecorder) Finally(ctx, hookContext, hookHints any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Finally", reflect.TypeOf((*MockHook)(nil).Finally), ctx, hookContext, hookHints) +} From 41d528d2f3b77ee5e34dbeec794ed7422fc18e46 Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sun, 4 May 2025 16:53:55 -0400 Subject: [PATCH 06/16] feat: Implement HookIsolator this is used to prevent context changes performed by hooks from leaking to other providers Signed-off-by: Jordan Blacker --- .../internal/wrappers/hook_isolator.go | 342 ++++++++++++++++++ .../internal/wrappers/hook_isolator_test.go | 101 ++++++ 2 files changed, 443 insertions(+) create mode 100644 providers/multi-provider/internal/wrappers/hook_isolator.go create mode 100644 providers/multi-provider/internal/wrappers/hook_isolator_test.go diff --git a/providers/multi-provider/internal/wrappers/hook_isolator.go b/providers/multi-provider/internal/wrappers/hook_isolator.go new file mode 100644 index 000000000..5ab83772d --- /dev/null +++ b/providers/multi-provider/internal/wrappers/hook_isolator.go @@ -0,0 +1,342 @@ +package wrappers + +import ( + "context" + "fmt" + of "github.com/open-feature/go-sdk/openfeature" + "slices" + "sync" +) + +type ( + // HookIsolator used as a wrapper around a provider that prevents context changes from leaking between providers + // during evaluation + HookIsolator struct { + mu sync.Mutex + of.FeatureProvider + hooks []of.Hook + capturedContext of.HookContext + capturedHints of.HookHints + } + + // EventHandlingHookIsolator is equivalent to HookIsolator, but also implements [of.EventHandler] + EventHandlingHookIsolator struct { + HookIsolator + } +) + +var ( + _ of.FeatureProvider = (*HookIsolator)(nil) + _ of.Hook = (*HookIsolator)(nil) + _ of.EventHandler = (*EventHandlingHookIsolator)(nil) +) + +func IsolateProvider(provider of.FeatureProvider, extraHooks []of.Hook) *HookIsolator { + return &HookIsolator{ + FeatureProvider: provider, + hooks: slices.Concat(provider.Hooks(), extraHooks), + } +} + +func IsolateProviderWithEvents(provider of.FeatureProvider, extraHooks []of.Hook) *EventHandlingHookIsolator { + return &EventHandlingHookIsolator{*IsolateProvider(provider, extraHooks)} +} + +func (h *EventHandlingHookIsolator) EventChannel() <-chan of.Event { + return h.FeatureProvider.(of.EventHandler).EventChannel() +} + +func (h *HookIsolator) Before(ctx context.Context, hookContext of.HookContext, hookHints of.HookHints) (*of.EvaluationContext, error) { + // Used for capturing the context and hints + h.mu.Lock() + defer h.mu.Unlock() + h.capturedContext = hookContext + h.capturedHints = hookHints + // Return copy of original evaluation context so any changes are isolated to each provider's hooks + evalCtx := h.capturedContext.EvaluationContext() + return &evalCtx, nil +} + +func (h *HookIsolator) After(ctx context.Context, hookContext of.HookContext, flagEvaluationDetails of.InterfaceEvaluationDetails, hookHints of.HookHints) error { + // Purposely left as a no-op + return nil +} + +func (h *HookIsolator) Error(ctx context.Context, hookContext of.HookContext, err error, hookHints of.HookHints) { + // Purposely left as a no-op +} + +func (h *HookIsolator) Finally(ctx context.Context, hookContext of.HookContext, hookHints of.HookHints) { + // Purposely left as a no-op +} + +func (h *HookIsolator) Metadata() of.Metadata { + return h.FeatureProvider.Metadata() +} + +func (h *HookIsolator) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail { + completeEval := h.evaluate(ctx, flag, of.Boolean, defaultValue, evalCtx) + + return of.BoolResolutionDetail{ + Value: completeEval.Value.(bool), + ProviderResolutionDetail: toProviderResolutionDetail(completeEval), + } +} + +func (h *HookIsolator) StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx of.FlattenedContext) of.StringResolutionDetail { + completeEval := h.evaluate(ctx, flag, of.String, defaultValue, evalCtx) + + return of.StringResolutionDetail{ + Value: completeEval.Value.(string), + ProviderResolutionDetail: toProviderResolutionDetail(completeEval), + } +} + +func (h *HookIsolator) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx of.FlattenedContext) of.FloatResolutionDetail { + completeEval := h.evaluate(ctx, flag, of.Float, defaultValue, evalCtx) + + return of.FloatResolutionDetail{ + Value: completeEval.Value.(float64), + ProviderResolutionDetail: toProviderResolutionDetail(completeEval), + } +} + +func (h *HookIsolator) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx of.FlattenedContext) of.IntResolutionDetail { + completeEval := h.evaluate(ctx, flag, of.Int, defaultValue, evalCtx) + + return of.IntResolutionDetail{ + Value: completeEval.Value.(int64), + ProviderResolutionDetail: toProviderResolutionDetail(completeEval), + } +} + +func (h *HookIsolator) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx of.FlattenedContext) of.InterfaceResolutionDetail { + completeEval := h.evaluate(ctx, flag, of.Object, defaultValue, evalCtx) + + return of.InterfaceResolutionDetail{ + Value: completeEval.Value, + ProviderResolutionDetail: toProviderResolutionDetail(completeEval), + } +} + +func toProviderResolutionDetail(evalDetails of.InterfaceEvaluationDetails) of.ProviderResolutionDetail { + var resolutionErr of.ResolutionError + var reason of.Reason + switch evalDetails.ErrorCode { + case of.GeneralCode: + resolutionErr = of.NewGeneralResolutionError(evalDetails.ErrorMessage) + reason = of.ErrorReason + case of.FlagNotFoundCode: + resolutionErr = of.NewFlagNotFoundResolutionError(evalDetails.ErrorMessage) + reason = of.DefaultReason + case of.TargetingKeyMissingCode: + resolutionErr = of.NewTargetingKeyMissingResolutionError(evalDetails.ErrorMessage) + reason = of.TargetingMatchReason + case of.TypeMismatchCode: + resolutionErr = of.NewTypeMismatchResolutionError(evalDetails.ErrorMessage) + reason = of.ErrorReason + case of.ParseErrorCode: + resolutionErr = of.NewParseErrorResolutionError(evalDetails.ErrorMessage) + reason = of.ErrorReason + case of.InvalidContextCode: + resolutionErr = of.NewInvalidContextResolutionError(evalDetails.ErrorMessage) + reason = of.ErrorReason + } + return of.ProviderResolutionDetail{ + ResolutionError: resolutionErr, + Reason: reason, + Variant: evalDetails.Variant, + FlagMetadata: evalDetails.FlagMetadata, + } +} + +func (h *HookIsolator) Hooks() []of.Hook { + // return self as hook to capture contexts + return []of.Hook{h} +} + +func (h *HookIsolator) evaluate(ctx context.Context, flag string, flagType of.Type, defaultValue interface{}, flatCtx of.FlattenedContext) of.InterfaceEvaluationDetails { + evalDetails := of.InterfaceEvaluationDetails{ + Value: defaultValue, + EvaluationDetails: of.EvaluationDetails{ + FlagKey: flag, + FlagType: flagType, + }, + } + + defer func() { + h.finallyHooks(ctx) + }() + + evalCtx, err := h.beforeHooks(ctx) + // Update hook context unconditionally + h.updateEvalContext(evalCtx) + if err != nil { + //h.logger.Error( + // err, "before hook", "flag", flag, "defaultValue", defaultValue, + // "evaluationContext", flatCtx, "evaluationOptions", options, "type", flagType.String(), + //) + err = fmt.Errorf("before hook: %w", err) + h.errorHooks(ctx, err) + evalDetails.ResolutionDetail = of.ResolutionDetail{ + Reason: of.ErrorReason, + ErrorCode: of.GeneralCode, + ErrorMessage: err.Error(), + FlagMetadata: nil, + } + return evalDetails + } + + // Merge together the passed in flat context and the captured evaluation context and transform back into a flattened + // context for evaluation + flatCtx = flattenContext(mergeContexts(h.capturedContext.EvaluationContext(), deepenContext(flatCtx))) + + var resolution of.InterfaceResolutionDetail + switch flagType { + case of.Object: + resolution = h.FeatureProvider.ObjectEvaluation(ctx, flag, defaultValue, flatCtx) + case of.Boolean: + defValue := defaultValue.(bool) + res := h.FeatureProvider.BooleanEvaluation(ctx, flag, defValue, flatCtx) + resolution.ProviderResolutionDetail = res.ProviderResolutionDetail + resolution.Value = res.Value + case of.String: + defValue := defaultValue.(string) + res := h.FeatureProvider.StringEvaluation(ctx, flag, defValue, flatCtx) + resolution.ProviderResolutionDetail = res.ProviderResolutionDetail + resolution.Value = res.Value + case of.Float: + defValue := defaultValue.(float64) + res := h.FeatureProvider.FloatEvaluation(ctx, flag, defValue, flatCtx) + resolution.ProviderResolutionDetail = res.ProviderResolutionDetail + resolution.Value = res.Value + case of.Int: + defValue := defaultValue.(int64) + res := h.FeatureProvider.IntEvaluation(ctx, flag, defValue, flatCtx) + resolution.ProviderResolutionDetail = res.ProviderResolutionDetail + resolution.Value = res.Value + } + + err = resolution.Error() + if err != nil { + //h.logger.Error( + // err, "flag resolution", "flag", flag, "defaultValue", defaultValue, + // "evaluationContext", flatCtx, "evaluationOptions", options, "type", flagType.String(), "errorCode", err, + // "errMessage", resolution.ResolutionError.message, + //) + err = fmt.Errorf("error code: %w", err) + h.errorHooks(ctx, err) + evalDetails.ResolutionDetail = resolution.ResolutionDetail() + evalDetails.Reason = of.ErrorReason + return evalDetails + } + evalDetails.Value = resolution.Value + evalDetails.ResolutionDetail = resolution.ResolutionDetail() + + if err := h.afterHooks(ctx, evalDetails); err != nil { + //h.logger.Error( + // err, "after hook", "flag", flag, "defaultValue", defaultValue, + // "evaluationContext", flatCtx, "evaluationOptions", options, "type", flagType.String(), + //) + err = fmt.Errorf("after hook: %w", err) + h.errorHooks(ctx, err) + return evalDetails + } + + return evalDetails +} + +func (h *HookIsolator) beforeHooks(ctx context.Context) (of.EvaluationContext, error) { + contexts := []of.EvaluationContext{h.capturedContext.EvaluationContext()} + for _, hook := range h.hooks { + resultEvalCtx, err := hook.Before(ctx, h.capturedContext, h.capturedHints) + if resultEvalCtx != nil { + contexts = append(contexts, *resultEvalCtx) + } + if err != nil { + return mergeContexts(contexts...), err + } + } + + return mergeContexts(contexts...), nil +} + +func (h *HookIsolator) afterHooks(ctx context.Context, evalDetails of.InterfaceEvaluationDetails) error { + for _, hook := range h.hooks { + if err := hook.After(ctx, h.capturedContext, evalDetails, h.capturedHints); err != nil { + return err + } + } + + return nil +} + +func (h *HookIsolator) errorHooks(ctx context.Context, err error) { + for _, hook := range h.hooks { + hook.Error(ctx, h.capturedContext, err, h.capturedHints) + } +} + +func (h *HookIsolator) finallyHooks(ctx context.Context) { + for _, hook := range h.hooks { + hook.Finally(ctx, h.capturedContext, h.capturedHints) + } +} + +// updateEvalContext Returns a new [of.HookContext] with an updated [of.EvaluationContext] value. [of.HookContext] is +// immutable, and this returns a new [of.HookContext] with all other values copied +func (h *HookIsolator) updateEvalContext(evalCtx of.EvaluationContext) { + hookCtx := of.NewHookContext( + h.capturedContext.FlagKey(), + h.capturedContext.FlagType(), + h.capturedContext.DefaultValue(), + h.capturedContext.ClientMetadata(), + h.capturedContext.ProviderMetadata(), + evalCtx, + ) + h.mu.Lock() + defer h.mu.Unlock() + h.capturedContext = hookCtx +} + +func deepenContext(flatCtx of.FlattenedContext) of.EvaluationContext { + noTargetingKey := make(map[string]interface{}) + for k, v := range flatCtx { + if k != "targetingKey" { + noTargetingKey[k] = v + } + } + return of.NewEvaluationContext(flatCtx["targetingKey"].(string), noTargetingKey) +} + +func flattenContext(evalCtx of.EvaluationContext) of.FlattenedContext { + flatCtx := evalCtx.Attributes() + flatCtx["targetingKey"] = evalCtx.TargetingKey() + return flatCtx +} + +// merges attributes from the given EvaluationContexts with the nth EvaluationContext taking precedence in case +// of any conflicts with the (n+1)th EvaluationContext +func mergeContexts(evaluationContexts ...of.EvaluationContext) of.EvaluationContext { + if len(evaluationContexts) == 0 { + return of.EvaluationContext{} + } + // create initial values + attributes := evaluationContexts[0].Attributes() + targetingKey := evaluationContexts[0].TargetingKey() + + for i := 1; i < len(evaluationContexts); i++ { + if targetingKey == "" && evaluationContexts[i].TargetingKey() != "" { + targetingKey = evaluationContexts[i].TargetingKey() + } + + for k, v := range evaluationContexts[i].Attributes() { + _, ok := attributes[k] + if !ok { + attributes[k] = v + } + } + } + + return of.NewEvaluationContext(targetingKey, attributes) +} diff --git a/providers/multi-provider/internal/wrappers/hook_isolator_test.go b/providers/multi-provider/internal/wrappers/hook_isolator_test.go new file mode 100644 index 000000000..4ead84fea --- /dev/null +++ b/providers/multi-provider/internal/wrappers/hook_isolator_test.go @@ -0,0 +1,101 @@ +package wrappers + +import ( + "errors" + "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" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "testing" +) + +func Test_HookIsolator_BeforeCapturesData(t *testing.T) { + hookCtx := of.NewHookContext( + "test-key", + of.Boolean, + false, + of.ClientMetadata{}, + of.Metadata{}, + of.NewEvaluationContext("target", map[string]interface{}{}), + ) + hookHints := of.HookHints{} + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + provider.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) + isolator := IsolateProvider(provider, []of.Hook{}) + assert.Zero(t, isolator.capturedContext) + assert.Zero(t, isolator.capturedHints) + evalCtx, err := isolator.Before(t.Context(), hookCtx, hookHints) + require.NoError(t, err) + assert.NotNil(t, evalCtx) + assert.Equal(t, hookCtx, isolator.capturedContext) + assert.Equal(t, hookHints, isolator.capturedHints) +} + +func Test_HookIsolator_Hooks_ReturnsSelf(t *testing.T) { + ctrl := gomock.NewController(t) + provider := mocks.NewMockFeatureProvider(ctrl) + provider.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) + isolator := IsolateProvider(provider, []of.Hook{}) + hooks := isolator.Hooks() + assert.NotEmpty(t, hooks) + assert.Same(t, isolator, hooks[0]) +} + +func Test_HookIsolator_ExecutesHooksDuringEvaluation_NoError(t *testing.T) { + ctrl := gomock.NewController(t) + testHook := mocks.NewMockHook(ctrl) + testHook.EXPECT().Before(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) + testHook.EXPECT().After(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + testHook.EXPECT().Finally(gomock.Any(), gomock.Any(), gomock.Any()) + testHook.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + provider := mocks.NewMockFeatureProvider(ctrl) + provider.EXPECT().Hooks().Return([]of.Hook{testHook}) + provider.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(of.BoolResolutionDetail{ + Value: true, + ProviderResolutionDetail: of.ProviderResolutionDetail{}, + }) + + isolator := IsolateProvider(provider, nil) + result := isolator.BooleanEvaluation(t.Context(), "test-flag", false, of.FlattenedContext{"targetingKey": "anon"}) + assert.True(t, result.Value) +} + +func Test_HookIsolator_ExecutesHooksDuringEvaluation_BeforeErrorAbortsExecution(t *testing.T) { + ctrl := gomock.NewController(t) + testHook := mocks.NewMockHook(ctrl) + testHook.EXPECT().Before(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("test error")) + testHook.EXPECT().After(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + testHook.EXPECT().Finally(gomock.Any(), gomock.Any(), gomock.Any()) + testHook.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) + + provider := mocks.NewMockFeatureProvider(ctrl) + provider.EXPECT().Hooks().Return([]of.Hook{testHook}) + + isolator := IsolateProvider(provider, nil) + result := isolator.BooleanEvaluation(t.Context(), "test-flag", false, of.FlattenedContext{"targetingKey": "anon"}) + assert.False(t, result.Value) +} + +func Test_HookIsolator_ExecutesHooksDuringEvaluation_WithAfterError(t *testing.T) { + ctrl := gomock.NewController(t) + testHook := mocks.NewMockHook(ctrl) + testHook.EXPECT().Before(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) + testHook.EXPECT().After(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("test error")) + testHook.EXPECT().Finally(gomock.Any(), gomock.Any(), gomock.Any()) + testHook.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) + + provider := mocks.NewMockFeatureProvider(ctrl) + provider.EXPECT().Hooks().Return([]of.Hook{testHook}) + provider.EXPECT().BooleanEvaluation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(of.BoolResolutionDetail{ + Value: false, + ProviderResolutionDetail: of.ProviderResolutionDetail{}, + }) + + isolator := IsolateProvider(provider, nil) + result := isolator.BooleanEvaluation(t.Context(), "test-flag", false, of.FlattenedContext{"targetingKey": "anon"}) + assert.False(t, result.Value) +} From d100aa28b1525f86b2d4a2179cbdd729d834d9ea Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sun, 4 May 2025 16:58:05 -0400 Subject: [PATCH 07/16] feat: Add hooks options Signed-off-by: Jordan Blacker --- providers/multi-provider/pkg/options.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/providers/multi-provider/pkg/options.go b/providers/multi-provider/pkg/options.go index b48336a7b..1017a529c 100644 --- a/providers/multi-provider/pkg/options.go +++ b/providers/multi-provider/pkg/options.go @@ -50,3 +50,22 @@ func WithCustomStrategy(s strategies.Strategy) Option { conf.customStrategy = s } } + +// WithGlobalHooks sets the global hooks for the provider. These are hooks that affect ALL providers. For hooks that +// target specific providers make sure to attach them to that provider directly, or use the WithProviderHook Option if +// that provider does not provide its own hook functionality +func WithGlobalHooks(hooks ...of.Hook) Option { + return func(conf *Configuration) { + conf.hooks = hooks + } +} + +// WithProviderHooks sets hooks that execute only for a specific provider. The providerName must match the unique provider +// name set during MultiProvider creation. This should only be used if you need hooks that execute around a specific +// provider, but that provider does not currently accept a way to set hooks. This option can be used multiple times using +// unique provider names. Using a provider name that is not known will cause an error. +func WithProviderHooks(providerName string, hooks ...of.Hook) Option { + return func(conf *Configuration) { + conf.providerHooks[providerName] = hooks + } +} From e28b28d9aa692ee8fd19418ea91da6c28b5090ea Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sun, 4 May 2025 16:59:38 -0400 Subject: [PATCH 08/16] refactor: Leverage IsolatedProvider implementations in configuration phase Signed-off-by: Jordan Blacker --- providers/multi-provider/pkg/providers.go | 29 +++++++++++++++---- .../multi-provider/pkg/providers_test.go | 9 ++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/providers/multi-provider/pkg/providers.go b/providers/multi-provider/pkg/providers.go index b82c52fa8..f8e8646db 100644 --- a/providers/multi-provider/pkg/providers.go +++ b/providers/multi-provider/pkg/providers.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "github.com/open-feature/go-sdk-contrib/providers/multi-provider/internal/logger" + "github.com/open-feature/go-sdk-contrib/providers/multi-provider/internal/wrappers" "github.com/open-feature/go-sdk-contrib/providers/multi-provider/pkg/strategies" "golang.org/x/sync/errgroup" "log/slog" @@ -34,9 +35,7 @@ type ( inboundEvents chan namedEvent workerGroup sync.WaitGroup shutdownFunc context.CancelFunc - providerStatusLock sync.Mutex - providerStatus map[string]of.State - initialized bool + globalHooks []of.Hook } // Configuration MultiProvider's internal configuration @@ -48,7 +47,8 @@ type ( publishEvents bool metadata *of.Metadata timeout time.Duration - hooks []of.Hook // Not implemented yet + hooks []of.Hook + providerHooks map[string][]of.Hook } // EvaluationStrategy Defines a strategy to use for resolving the result from multiple providers @@ -163,20 +163,37 @@ func NewMultiProvider(providerMap ProviderMap, evaluationStrategy EvaluationStra } config := &Configuration{ - logger: slog.Default(), // Logging enabled by default using default slog logger + logger: slog.Default(), // Logging enabled by default using default slog logger + providerHooks: make(map[string][]of.Hook), } for _, opt := range options { opt(config) } + providers := providerMap + // Wrap any providers that include hooks + for name, provider := range providerMap { + if (len(provider.Hooks()) + len(config.providerHooks[name])) == 0 { + continue + } + + if _, ok := provider.(of.EventHandler); ok { + providers[name] = wrappers.IsolateProviderWithEvents(provider, config.providerHooks[name]) + continue + } + + providers[name] = wrappers.IsolateProvider(provider, config.providerHooks[name]) + } + multiProvider := &MultiProvider{ - providers: providerMap, + providers: providers, outboundEvents: make(chan of.Event), logger: logger.NewConditionalLogger(config.logger), metadata: providerMap.buildMetadata(), totalStatus: of.NotReadyState, providerStatus: make(map[string]of.State), + globalHooks: config.hooks, } var zeroDuration time.Duration diff --git a/providers/multi-provider/pkg/providers_test.go b/providers/multi-provider/pkg/providers_test.go index c9ed2896a..4c2723f43 100644 --- a/providers/multi-provider/pkg/providers_test.go +++ b/providers/multi-provider/pkg/providers_test.go @@ -114,6 +114,7 @@ func TestMultiProvider_MetaData(t *testing.T) { testProvider2.EXPECT().Metadata().Return(of.Metadata{ Name: "MockProvider", }) + testProvider2.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) providers := make(ProviderMap) providers["provider1"] = testProvider1 @@ -132,9 +133,11 @@ func TestMultiProvider_Init(t *testing.T) { testProvider1 := mocks.NewMockFeatureProvider(ctrl) testProvider1.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + testProvider1.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) testProvider2 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) testProvider3 := mocks.NewMockFeatureProvider(ctrl) testProvider3.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + testProvider3.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) providers := make(ProviderMap) providers["provider1"] = testProvider1 @@ -182,6 +185,7 @@ func TestMultiProvider_InitErrorWithProvider(t *testing.T) { ctrl := gomock.NewController(t) errProvider := mocks.NewMockFeatureProvider(ctrl) errProvider.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + errProvider.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) errHandler := mocks.NewMockStateHandler(ctrl) errHandler.EXPECT().Init(gomock.Any()).Return(errors.New("test error")) testProvider3 := struct { @@ -193,6 +197,7 @@ func TestMultiProvider_InitErrorWithProvider(t *testing.T) { } testProvider1 := mocks.NewMockFeatureProvider(ctrl) + testProvider1.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) testProvider1.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) testProvider2 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) @@ -241,9 +246,11 @@ func TestMultiProvider_Shutdown_WithoutInit(t *testing.T) { testProvider1 := mocks.NewMockFeatureProvider(ctrl) testProvider1.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + testProvider1.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) testProvider2 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) testProvider3 := mocks.NewMockFeatureProvider(ctrl) testProvider3.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + testProvider3.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) providers := make(ProviderMap) providers["provider1"] = testProvider1 @@ -260,9 +267,11 @@ func TestMultiProvider_Shutdown_WithInit(t *testing.T) { testProvider1 := mocks.NewMockFeatureProvider(ctrl) testProvider1.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + testProvider1.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) testProvider2 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) handlingProvider := mocks.NewMockFeatureProvider(ctrl) handlingProvider.EXPECT().Metadata().Return(of.Metadata{Name: "MockProvider"}) + handlingProvider.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) handledHandler := mocks.NewMockStateHandler(ctrl) handledHandler.EXPECT().Init(gomock.Any()).Return(nil) handledHandler.EXPECT().Shutdown() From 1c1adcb4b1e2a6421a781f26c2d92bb040ae9ec4 Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sun, 4 May 2025 17:04:13 -0400 Subject: [PATCH 09/16] chore: lint all the things Signed-off-by: Jordan Blacker --- .../internal/wrappers/hook_isolator_test.go | 9 +++++---- providers/multi-provider/pkg/providers.go | 11 +++++------ providers/multi-provider/pkg/providers_test.go | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/providers/multi-provider/internal/wrappers/hook_isolator_test.go b/providers/multi-provider/internal/wrappers/hook_isolator_test.go index 4ead84fea..d6cf5e879 100644 --- a/providers/multi-provider/internal/wrappers/hook_isolator_test.go +++ b/providers/multi-provider/internal/wrappers/hook_isolator_test.go @@ -1,6 +1,7 @@ package wrappers import ( + "context" "errors" "github.com/open-feature/go-sdk-contrib/providers/multi-provider/internal/mocks" of "github.com/open-feature/go-sdk/openfeature" @@ -27,7 +28,7 @@ func Test_HookIsolator_BeforeCapturesData(t *testing.T) { isolator := IsolateProvider(provider, []of.Hook{}) assert.Zero(t, isolator.capturedContext) assert.Zero(t, isolator.capturedHints) - evalCtx, err := isolator.Before(t.Context(), hookCtx, hookHints) + evalCtx, err := isolator.Before(context.Background(), hookCtx, hookHints) require.NoError(t, err) assert.NotNil(t, evalCtx) assert.Equal(t, hookCtx, isolator.capturedContext) @@ -60,7 +61,7 @@ func Test_HookIsolator_ExecutesHooksDuringEvaluation_NoError(t *testing.T) { }) isolator := IsolateProvider(provider, nil) - result := isolator.BooleanEvaluation(t.Context(), "test-flag", false, of.FlattenedContext{"targetingKey": "anon"}) + result := isolator.BooleanEvaluation(context.Background(), "test-flag", false, of.FlattenedContext{"targetingKey": "anon"}) assert.True(t, result.Value) } @@ -76,7 +77,7 @@ func Test_HookIsolator_ExecutesHooksDuringEvaluation_BeforeErrorAbortsExecution( provider.EXPECT().Hooks().Return([]of.Hook{testHook}) isolator := IsolateProvider(provider, nil) - result := isolator.BooleanEvaluation(t.Context(), "test-flag", false, of.FlattenedContext{"targetingKey": "anon"}) + result := isolator.BooleanEvaluation(context.Background(), "test-flag", false, of.FlattenedContext{"targetingKey": "anon"}) assert.False(t, result.Value) } @@ -96,6 +97,6 @@ func Test_HookIsolator_ExecutesHooksDuringEvaluation_WithAfterError(t *testing.T }) isolator := IsolateProvider(provider, nil) - result := isolator.BooleanEvaluation(t.Context(), "test-flag", false, of.FlattenedContext{"targetingKey": "anon"}) + result := isolator.BooleanEvaluation(context.Background(), "test-flag", false, of.FlattenedContext{"targetingKey": "anon"}) assert.False(t, result.Value) } diff --git a/providers/multi-provider/pkg/providers.go b/providers/multi-provider/pkg/providers.go index f8e8646db..ce36b8cc6 100644 --- a/providers/multi-provider/pkg/providers.go +++ b/providers/multi-provider/pkg/providers.go @@ -44,8 +44,6 @@ type ( fallbackProvider of.FeatureProvider customStrategy strategies.Strategy logger *slog.Logger - publishEvents bool - metadata *of.Metadata timeout time.Duration hooks []of.Hook providerHooks map[string][]of.Hook @@ -77,10 +75,11 @@ const ( 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" - MetadataProviderName = "multiprovider-provider-name" - MetadataProviderType = "multiprovider-provider-type" - MetadataInternalError = "multiprovider-internal-error" + StrategyCustom EvaluationStrategy = "strategy-custom" + + MetadataProviderName = "multiprovider-provider-name" + MetadataProviderType = "multiprovider-provider-type" + MetadataInternalError = "multiprovider-internal-error" ) var ( diff --git a/providers/multi-provider/pkg/providers_test.go b/providers/multi-provider/pkg/providers_test.go index 4c2723f43..d51b22e19 100644 --- a/providers/multi-provider/pkg/providers_test.go +++ b/providers/multi-provider/pkg/providers_test.go @@ -152,7 +152,7 @@ func TestMultiProvider_Init(t *testing.T) { } evalCtx := openfeature.NewTargetlessEvaluationContext(attributes) eventChan := make(chan of.Event) - ctx, cancel := context.WithCancel(t.Context()) + ctx, cancel := context.WithCancel(context.Background()) go func() { select { case e := <-mp.EventChannel(): @@ -214,7 +214,7 @@ func TestMultiProvider_InitErrorWithProvider(t *testing.T) { } evalCtx := openfeature.NewTargetlessEvaluationContext(attributes) eventChan := make(chan of.Event) - ctx, cancel := context.WithCancel(t.Context()) + ctx, cancel := context.WithCancel(context.Background()) go func() { select { case e := <-mp.EventChannel(): @@ -293,7 +293,7 @@ func TestMultiProvider_Shutdown_WithInit(t *testing.T) { "foo": "bar", }) eventChan := make(chan of.Event) - ctx, cancel := context.WithCancel(t.Context()) + ctx, cancel := context.WithCancel(context.Background()) go func() { select { case e := <-mp.EventChannel(): From 169b3e21a6003ce8a6dda43c6ffcc18aa60edc09 Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sun, 4 May 2025 17:04:30 -0400 Subject: [PATCH 10/16] feat: Add lint recipe to Makefile Signed-off-by: Jordan Blacker --- providers/multi-provider/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/multi-provider/Makefile b/providers/multi-provider/Makefile index 29ec5bf81..fe839976d 100644 --- a/providers/multi-provider/Makefile +++ b/providers/multi-provider/Makefile @@ -1,4 +1,4 @@ -.PHONY: generate test +.PHONY: generate test lint GOPATH_LOC = ${GOPATH} generate: From 70ab6a6f74190fd94745ceb74465b2f6ba51dbf2 Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sun, 4 May 2025 17:09:40 -0400 Subject: [PATCH 11/16] refactor: Improve isolator test Signed-off-by: Jordan Blacker --- .../multi-provider/internal/wrappers/hook_isolator_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/providers/multi-provider/internal/wrappers/hook_isolator_test.go b/providers/multi-provider/internal/wrappers/hook_isolator_test.go index d6cf5e879..cad52374d 100644 --- a/providers/multi-provider/internal/wrappers/hook_isolator_test.go +++ b/providers/multi-provider/internal/wrappers/hook_isolator_test.go @@ -21,7 +21,9 @@ func Test_HookIsolator_BeforeCapturesData(t *testing.T) { of.Metadata{}, of.NewEvaluationContext("target", map[string]interface{}{}), ) - hookHints := of.HookHints{} + require.NotZero(t, hookCtx) + hookHints := of.NewHookHints(map[string]interface{}{"foo": "bar"}) + require.NotZero(t, hookHints) ctrl := gomock.NewController(t) provider := mocks.NewMockFeatureProvider(ctrl) provider.EXPECT().Hooks().Return([]of.Hook{}).MinTimes(1) From bf867e918df8967ce4a58bc442434b370581dc88 Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sun, 4 May 2025 17:18:26 -0400 Subject: [PATCH 12/16] doc: Update multiprovider readme Signed-off-by: Jordan Blacker --- providers/multi-provider/README.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/providers/multi-provider/README.md b/providers/multi-provider/README.md index b2bab36e1..4d9488e10 100644 --- a/providers/multi-provider/README.md +++ b/providers/multi-provider/README.md @@ -42,7 +42,14 @@ openfeature.SetProvider(provider) - `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 +- `WithLogger` - Provides slog support using the specified logger +- `WithLoggerDefault` - Default setting. Uses the slog default logger +- `WithoutLogging` - Disables internal logging of the multiprovider +- `WithCustomStrategy` - Allows for passing in an instance of a custom `Strategy` implementation. Must be used in +conjunction with the `StrategyCustom` `EvaluationStrategy` parameter. +- `WithGlobalHooks` - Sets any hooks that should be executed globally across all internal providers. For hooks targeting +specific providers they should either be attached directly to the provider or use `WithProviderHooks` +- `WithProviderHooks` - Sets any hooks that should be executed only for a specific named provider # Strategies @@ -77,8 +84,12 @@ returned. If a provider returns `FLAG_NOT_FOUND` that is not included in the com 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` +## Custom + +Users can opt to write their own strategy by implementing the interface if they have needs that the three built-in +strategies cannot meet. When setting the `StrategyCustom` strategy make sure to pass in an instance of your `Strategy` +implementation using the `WithCustomStrategy` option. + # Not Yet Implemented -- Hooks support -- Event support - Full slog support \ No newline at end of file From dcf8ab266c296ebdb324bb284d1df04c30367e38 Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Sun, 4 May 2025 17:32:28 -0400 Subject: [PATCH 13/16] refactor: Optimize & fix bug in constructor - Made sure the hooks exposed from `HookIsolators` are exposed to the global hooks method - Combined two loops into one for validation & wrapping Signed-off-by: Jordan Blacker --- providers/multi-provider/pkg/providers.go | 33 ++++++++++++----------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/providers/multi-provider/pkg/providers.go b/providers/multi-provider/pkg/providers.go index ce36b8cc6..63e94b030 100644 --- a/providers/multi-provider/pkg/providers.go +++ b/providers/multi-provider/pkg/providers.go @@ -150,16 +150,6 @@ func NewMultiProvider(providerMap ProviderMap, evaluationStrategy EvaluationStra 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(), // Logging enabled by default using default slog logger @@ -171,18 +161,31 @@ func NewMultiProvider(providerMap ProviderMap, evaluationStrategy EvaluationStra } providers := providerMap - // Wrap any providers that include hooks + collectedHooks := make([]of.Hook, 0, len(providerMap)) for name, provider := range providerMap { + // Validate Providers + 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) + } + + // Wrap any providers that include hooks if (len(provider.Hooks()) + len(config.providerHooks[name])) == 0 { continue } + var wrappedProvider of.FeatureProvider if _, ok := provider.(of.EventHandler); ok { - providers[name] = wrappers.IsolateProviderWithEvents(provider, config.providerHooks[name]) - continue + wrappedProvider = wrappers.IsolateProviderWithEvents(provider, config.providerHooks[name]) + } else { + wrappedProvider = wrappers.IsolateProvider(provider, config.providerHooks[name]) } - providers[name] = wrappers.IsolateProvider(provider, config.providerHooks[name]) + providers[name] = wrappedProvider + collectedHooks = append(collectedHooks, wrappedProvider.Hooks()...) } multiProvider := &MultiProvider{ @@ -192,7 +195,7 @@ func NewMultiProvider(providerMap ProviderMap, evaluationStrategy EvaluationStra metadata: providerMap.buildMetadata(), totalStatus: of.NotReadyState, providerStatus: make(map[string]of.State), - globalHooks: config.hooks, + globalHooks: slices.Concat(config.hooks, collectedHooks), } var zeroDuration time.Duration From b02ed7efede1d43f32a14832d1a9a15a17700428 Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Wed, 7 May 2025 14:03:21 -0400 Subject: [PATCH 14/16] refactor: Improve Makefile Makefile updated to remove hardcoding of sdk version and clean up of unnecessary comments Co-authored-by: Roman Dmytrenko Signed-off-by: Jordan Blacker --- providers/multi-provider/Makefile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/providers/multi-provider/Makefile b/providers/multi-provider/Makefile index fe839976d..8a436e43d 100644 --- a/providers/multi-provider/Makefile +++ b/providers/multi-provider/Makefile @@ -1,11 +1,13 @@ .PHONY: generate test lint GOPATH_LOC = ${GOPATH} +OF_SDK_DIR := $(shell go list -f '{{.Dir}}' github.com/open-feature/go-sdk/openfeature) + 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 - mockgen -source=${GOPATH}/pkg/mod/github.com/open-feature/go-sdk@v1.13.1/openfeature/hooks.go -package=mocks -destination=./internal/mocks/hook_mock.go + mockgen -source=${OF_SDK_DIR}/provider.go -write_source_comment=false -write_command_comment=false -package=mocks -destination=./internal/mocks/provider_mock.go + mockgen -source=${OF_SDK_DIR}/hooks.go -write_source_comment=false -write_command_comment=false -package=mocks -destination=./internal/mocks/hook_mock.go lint: go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.5 From 7dde38704dbbbf318e6ec464f916e09e9158c8a4 Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Wed, 14 May 2025 21:42:47 -0400 Subject: [PATCH 15/16] fix: Update mock generation in makefile & go:generate command Used package generation to avoid needing to specify version & not including home dir info Signed-off-by: Jordan Blacker --- providers/multi-provider/Makefile | 3 --- providers/multi-provider/internal/mocks/mocks.go | 2 ++ providers/multi-provider/pkg/strategies/strategies.go | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 providers/multi-provider/internal/mocks/mocks.go diff --git a/providers/multi-provider/Makefile b/providers/multi-provider/Makefile index 8a436e43d..fea2a850f 100644 --- a/providers/multi-provider/Makefile +++ b/providers/multi-provider/Makefile @@ -5,9 +5,6 @@ OF_SDK_DIR := $(shell go list -f '{{.Dir}}' github.com/open-feature/go-sdk/openf generate: go generate ./... - go mod download - mockgen -source=${OF_SDK_DIR}/provider.go -write_source_comment=false -write_command_comment=false -package=mocks -destination=./internal/mocks/provider_mock.go - mockgen -source=${OF_SDK_DIR}/hooks.go -write_source_comment=false -write_command_comment=false -package=mocks -destination=./internal/mocks/hook_mock.go lint: go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.5 diff --git a/providers/multi-provider/internal/mocks/mocks.go b/providers/multi-provider/internal/mocks/mocks.go new file mode 100644 index 000000000..16dd89cf9 --- /dev/null +++ b/providers/multi-provider/internal/mocks/mocks.go @@ -0,0 +1,2 @@ +//go:generate go run go.uber.org/mock/mockgen -destination=../../internal/mocks/openfeature_mocks.go -package=mocks "github.com/open-feature/go-sdk/openfeature" FeatureProvider,Hook +package mocks diff --git a/providers/multi-provider/pkg/strategies/strategies.go b/providers/multi-provider/pkg/strategies/strategies.go index 4670acfc6..f03aeaf0c 100644 --- a/providers/multi-provider/pkg/strategies/strategies.go +++ b/providers/multi-provider/pkg/strategies/strategies.go @@ -1,6 +1,6 @@ // 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 +//go:generate go run go.uber.org/mock/mockgen -destination=../../pkg/strategies/strategy_mock.go -package=strategies -write_source_comment=false . Strategy package strategies import ( From dd50b782eb8d8e7c1b4fab9bce918a75708d0fe0 Mon Sep 17 00:00:00 2001 From: Jordan Blacker Date: Wed, 14 May 2025 21:41:43 -0400 Subject: [PATCH 16/16] chore: regenerate mocks Signed-off-by: Jordan Blacker --- .../internal/mocks/hook_mock.go | 95 ------------------- .../multi-provider/internal/mocks/mocks.go | 2 +- ...{provider_mock.go => openfeature_mocks.go} | 81 +++++++++++++++- .../pkg/strategies/strategy_mock.go | 3 +- 4 files changed, 81 insertions(+), 100 deletions(-) delete mode 100644 providers/multi-provider/internal/mocks/hook_mock.go rename providers/multi-provider/internal/mocks/{provider_mock.go => openfeature_mocks.go} (73%) diff --git a/providers/multi-provider/internal/mocks/hook_mock.go b/providers/multi-provider/internal/mocks/hook_mock.go deleted file mode 100644 index 87ae8f93e..000000000 --- a/providers/multi-provider/internal/mocks/hook_mock.go +++ /dev/null @@ -1,95 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: /Users/jordanblacker/go/pkg/mod/github.com/open-feature/go-sdk@v1.13.1/openfeature/hooks.go -// -// Generated by this command: -// -// mockgen -source=/Users/jordanblacker/go/pkg/mod/github.com/open-feature/go-sdk@v1.13.1/openfeature/hooks.go -package=mocks -destination=./internal/mocks/hook_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" -) - -// MockHook is a mock of Hook interface. -type MockHook struct { - ctrl *gomock.Controller - recorder *MockHookMockRecorder - isgomock struct{} -} - -// MockHookMockRecorder is the mock recorder for MockHook. -type MockHookMockRecorder struct { - mock *MockHook -} - -// NewMockHook creates a new mock instance. -func NewMockHook(ctrl *gomock.Controller) *MockHook { - mock := &MockHook{ctrl: ctrl} - mock.recorder = &MockHookMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockHook) EXPECT() *MockHookMockRecorder { - return m.recorder -} - -// After mocks base method. -func (m *MockHook) After(ctx context.Context, hookContext openfeature.HookContext, flagEvaluationDetails openfeature.InterfaceEvaluationDetails, hookHints openfeature.HookHints) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "After", ctx, hookContext, flagEvaluationDetails, hookHints) - ret0, _ := ret[0].(error) - return ret0 -} - -// After indicates an expected call of After. -func (mr *MockHookMockRecorder) After(ctx, hookContext, flagEvaluationDetails, hookHints any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "After", reflect.TypeOf((*MockHook)(nil).After), ctx, hookContext, flagEvaluationDetails, hookHints) -} - -// Before mocks base method. -func (m *MockHook) Before(ctx context.Context, hookContext openfeature.HookContext, hookHints openfeature.HookHints) (*openfeature.EvaluationContext, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Before", ctx, hookContext, hookHints) - ret0, _ := ret[0].(*openfeature.EvaluationContext) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Before indicates an expected call of Before. -func (mr *MockHookMockRecorder) Before(ctx, hookContext, hookHints any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Before", reflect.TypeOf((*MockHook)(nil).Before), ctx, hookContext, hookHints) -} - -// Error mocks base method. -func (m *MockHook) Error(ctx context.Context, hookContext openfeature.HookContext, err error, hookHints openfeature.HookHints) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "Error", ctx, hookContext, err, hookHints) -} - -// Error indicates an expected call of Error. -func (mr *MockHookMockRecorder) Error(ctx, hookContext, err, hookHints any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockHook)(nil).Error), ctx, hookContext, err, hookHints) -} - -// Finally mocks base method. -func (m *MockHook) Finally(ctx context.Context, hookContext openfeature.HookContext, hookHints openfeature.HookHints) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "Finally", ctx, hookContext, hookHints) -} - -// Finally indicates an expected call of Finally. -func (mr *MockHookMockRecorder) Finally(ctx, hookContext, hookHints any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Finally", reflect.TypeOf((*MockHook)(nil).Finally), ctx, hookContext, hookHints) -} diff --git a/providers/multi-provider/internal/mocks/mocks.go b/providers/multi-provider/internal/mocks/mocks.go index 16dd89cf9..5bbbec202 100644 --- a/providers/multi-provider/internal/mocks/mocks.go +++ b/providers/multi-provider/internal/mocks/mocks.go @@ -1,2 +1,2 @@ -//go:generate go run go.uber.org/mock/mockgen -destination=../../internal/mocks/openfeature_mocks.go -package=mocks "github.com/open-feature/go-sdk/openfeature" FeatureProvider,Hook +//go:generate go run go.uber.org/mock/mockgen -destination=../../internal/mocks/openfeature_mocks.go -package=mocks "github.com/open-feature/go-sdk/openfeature" FeatureProvider,Hook,StateHandler,EventHandler package mocks diff --git a/providers/multi-provider/internal/mocks/provider_mock.go b/providers/multi-provider/internal/mocks/openfeature_mocks.go similarity index 73% rename from providers/multi-provider/internal/mocks/provider_mock.go rename to providers/multi-provider/internal/mocks/openfeature_mocks.go index 2f0675dd3..c4a65c8a7 100644 --- a/providers/multi-provider/internal/mocks/provider_mock.go +++ b/providers/multi-provider/internal/mocks/openfeature_mocks.go @@ -1,9 +1,9 @@ // 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 +// Source: github.com/open-feature/go-sdk/openfeature (interfaces: FeatureProvider,Hook,StateHandler,EventHandler) // // 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 +// mockgen -destination=../../internal/mocks/openfeature_mocks.go -package=mocks github.com/open-feature/go-sdk/openfeature FeatureProvider,Hook,StateHandler,EventHandler // // Package mocks is a generated GoMock package. @@ -139,6 +139,83 @@ func (mr *MockFeatureProviderMockRecorder) StringEvaluation(ctx, flag, defaultVa return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StringEvaluation", reflect.TypeOf((*MockFeatureProvider)(nil).StringEvaluation), ctx, flag, defaultValue, evalCtx) } +// MockHook is a mock of Hook interface. +type MockHook struct { + ctrl *gomock.Controller + recorder *MockHookMockRecorder + isgomock struct{} +} + +// MockHookMockRecorder is the mock recorder for MockHook. +type MockHookMockRecorder struct { + mock *MockHook +} + +// NewMockHook creates a new mock instance. +func NewMockHook(ctrl *gomock.Controller) *MockHook { + mock := &MockHook{ctrl: ctrl} + mock.recorder = &MockHookMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHook) EXPECT() *MockHookMockRecorder { + return m.recorder +} + +// After mocks base method. +func (m *MockHook) After(ctx context.Context, hookContext openfeature.HookContext, flagEvaluationDetails openfeature.InterfaceEvaluationDetails, hookHints openfeature.HookHints) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "After", ctx, hookContext, flagEvaluationDetails, hookHints) + ret0, _ := ret[0].(error) + return ret0 +} + +// After indicates an expected call of After. +func (mr *MockHookMockRecorder) After(ctx, hookContext, flagEvaluationDetails, hookHints any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "After", reflect.TypeOf((*MockHook)(nil).After), ctx, hookContext, flagEvaluationDetails, hookHints) +} + +// Before mocks base method. +func (m *MockHook) Before(ctx context.Context, hookContext openfeature.HookContext, hookHints openfeature.HookHints) (*openfeature.EvaluationContext, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Before", ctx, hookContext, hookHints) + ret0, _ := ret[0].(*openfeature.EvaluationContext) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Before indicates an expected call of Before. +func (mr *MockHookMockRecorder) Before(ctx, hookContext, hookHints any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Before", reflect.TypeOf((*MockHook)(nil).Before), ctx, hookContext, hookHints) +} + +// Error mocks base method. +func (m *MockHook) Error(ctx context.Context, hookContext openfeature.HookContext, err error, hookHints openfeature.HookHints) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Error", ctx, hookContext, err, hookHints) +} + +// Error indicates an expected call of Error. +func (mr *MockHookMockRecorder) Error(ctx, hookContext, err, hookHints any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockHook)(nil).Error), ctx, hookContext, err, hookHints) +} + +// Finally mocks base method. +func (m *MockHook) Finally(ctx context.Context, hookContext openfeature.HookContext, hookHints openfeature.HookHints) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Finally", ctx, hookContext, hookHints) +} + +// Finally indicates an expected call of Finally. +func (mr *MockHookMockRecorder) Finally(ctx, hookContext, hookHints any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Finally", reflect.TypeOf((*MockHook)(nil).Finally), ctx, hookContext, hookHints) +} + // MockStateHandler is a mock of StateHandler interface. type MockStateHandler struct { ctrl *gomock.Controller diff --git a/providers/multi-provider/pkg/strategies/strategy_mock.go b/providers/multi-provider/pkg/strategies/strategy_mock.go index 2bf1ad1a5..2f32b5ed2 100644 --- a/providers/multi-provider/pkg/strategies/strategy_mock.go +++ b/providers/multi-provider/pkg/strategies/strategy_mock.go @@ -1,9 +1,8 @@ // 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 +// mockgen -destination=../../pkg/strategies/strategy_mock.go -package=strategies -write_source_comment=false . Strategy // // Package strategies is a generated GoMock package.