diff --git a/Makefile b/Makefile index 79c9e25..dde0a52 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-example-1 clean setup-monorepo update-monorepo +.PHONY: build test test-openfeature test-example-1 clean setup-monorepo update-monorepo build: mkdir -p build @@ -6,9 +6,13 @@ build: test: go test ./... + $(MAKE) test-openfeature + +test-openfeature: + (cd openfeature && GOWORK=off go test ./...) test-example-1: - go test ./... + $(MAKE) test go run cmd/main.go test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures clean: diff --git a/README.md b/README.md index 20efb24..d77bb5a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ See example application [here](https://github.com/featurevisor/featurevisor-exam - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) +- [OpenFeature](#openfeature) - [CLI usage](#cli-usage) - [Test](#test) - [Benchmark](#benchmark) @@ -839,6 +840,61 @@ go run cmd/main.go assess-distribution \ --n=1000 ``` +## OpenFeature + +The OpenFeature provider is a separate Go module, so applications that do not use OpenFeature do not receive its dependencies: + +```bash +go get github.com/featurevisor/featurevisor-go/openfeature +``` + +```go +import ( + "context" + + featurevisor "github.com/featurevisor/featurevisor-go" + featurevisorof "github.com/featurevisor/featurevisor-go/openfeature" + of "github.com/open-feature/go-sdk/openfeature" +) + +provider := featurevisorof.NewProvider(featurevisorof.Options{ + FeaturevisorOptions: featurevisor.FeaturevisorOptions{ + Datafile: datafileContent, + }, +}) + +if err := of.SetProviderAndWait(provider); err != nil { + panic(err) +} + +client := of.NewClient("") +enabled, err := client.BooleanValue( + context.Background(), + "checkout", + false, + of.NewEvaluationContext("user-123", map[string]any{"country": "nl"}), +) +``` + +Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Arrays, objects, and JSON variables use the object resolver. + +OpenFeature's targeting key maps to `userId` by default. `TargetingKeyField`, `KeySeparator`, and `VariationKey` can customize the mapping. The provider's separate module follows the Go version requirement of the official OpenFeature Go SDK. + +You can also reuse an existing Featurevisor instance: + +```go +fv := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ + Datafile: datafileContent, +}) +provider := featurevisorof.NewProvider(featurevisorof.Options{ + Featurevisor: fv, +}) +``` + +The caller owns an instance passed this way. Provider shutdown does not close it. Call `fv.Close()` when every consumer is finished with it. When the provider creates the instance from `FeaturevisorOptions`, the provider owns and closes it. If both fields are supplied, `Featurevisor` takes precedence over `FeaturevisorOptions`. + +See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages. + ## Development of this package diff --git a/openfeature/go.mod b/openfeature/go.mod new file mode 100644 index 0000000..cf7dbd1 --- /dev/null +++ b/openfeature/go.mod @@ -0,0 +1,15 @@ +module github.com/featurevisor/featurevisor-go/openfeature + +go 1.25.0 + +require ( + github.com/featurevisor/featurevisor-go v1.0.0 + github.com/open-feature/go-sdk v1.17.2 +) + +require ( + github.com/go-logr/logr v1.4.3 // indirect + go.uber.org/mock v0.6.0 // indirect +) + +replace github.com/featurevisor/featurevisor-go => .. diff --git a/openfeature/go.sum b/openfeature/go.sum new file mode 100644 index 0000000..880c15f --- /dev/null +++ b/openfeature/go.sum @@ -0,0 +1,16 @@ +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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/open-feature/go-sdk v1.17.2 h1:pTdeNks/hgnPrlqdgtFwltnIron1oOxqg4FmLlirJlY= +github.com/open-feature/go-sdk v1.17.2/go.mod h1:kTMCquVtck18XdSCI6rBoNFEBLvkOy4Tphu2pV8bq34= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +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/openfeature/provider.go b/openfeature/provider.go new file mode 100644 index 0000000..ef58133 --- /dev/null +++ b/openfeature/provider.go @@ -0,0 +1,360 @@ +// Package openfeature provides the OpenFeature adapter for Featurevisor. +package openfeature + +import ( + "context" + "encoding/json" + "fmt" + "math" + "reflect" + "strings" + "time" + + featurevisor "github.com/featurevisor/featurevisor-go" + of "github.com/open-feature/go-sdk/openfeature" +) + +type TrackingEvent struct { + Name string + Context of.EvaluationContext + Details of.TrackingEventDetails +} + +type Options struct { + Featurevisor *featurevisor.Featurevisor + FeaturevisorOptions featurevisor.FeaturevisorOptions + TargetingKeyField string + KeySeparator string + VariationKey string + OnTrack func(TrackingEvent) +} + +type Provider struct { + featurevisor *featurevisor.Featurevisor + targetingKeyField string + keySeparator string + variationKey string + onTrack func(TrackingEvent) + datafileError string + datafileUnsubscribe featurevisor.Unsubscribe + ownsFeaturevisor bool +} + +func NewProvider(options Options) *Provider { + p := &Provider{ + targetingKeyField: valueOr(options.TargetingKeyField, "userId"), + keySeparator: valueOr(options.KeySeparator, ":"), + variationKey: valueOr(options.VariationKey, "variation"), + onTrack: options.OnTrack, + } + if options.Featurevisor != nil { + p.featurevisor = options.Featurevisor + } else { + p.ownsFeaturevisor = true + fvOptions := options.FeaturevisorOptions + if datafile, ok := fvOptions.Datafile.(string); ok && datafile != "" && !json.Valid([]byte(datafile)) { + p.datafileError = "Could not parse datafile" + } + originalHandler := fvOptions.OnDiagnostic + fvOptions.OnDiagnostic = func(diagnostic featurevisor.FeaturevisorDiagnostic) { + if diagnostic.Code == "invalid_datafile" { + p.datafileError = diagnostic.Message + } + if diagnostic.Code == "datafile_set" { + p.datafileError = "" + } + if originalHandler != nil { + originalHandler(diagnostic) + } + } + p.featurevisor = featurevisor.CreateFeaturevisor(fvOptions) + } + p.datafileUnsubscribe = p.featurevisor.On(featurevisor.EventNameDatafileSet, func(featurevisor.EventDetails) { + p.datafileError = "" + }) + return p +} + +func (p *Provider) Featurevisor() *featurevisor.Featurevisor { return p.featurevisor } +func (p *Provider) Metadata() of.Metadata { return of.Metadata{Name: "Featurevisor"} } +func (p *Provider) Hooks() []of.Hook { return nil } +func (p *Provider) Init(of.EvaluationContext) error { return nil } +func (p *Provider) Shutdown() { + if p.datafileUnsubscribe != nil { + p.datafileUnsubscribe() + } + if p.ownsFeaturevisor { + p.featurevisor.Close() + } +} + +func (p *Provider) Track(_ context.Context, name string, evaluationContext of.EvaluationContext, details of.TrackingEventDetails) { + if p.onTrack != nil { + p.onTrack(TrackingEvent{Name: name, Context: evaluationContext, Details: details}) + } +} + +func (p *Provider) BooleanEvaluation(_ context.Context, flag string, defaultValue bool, flatCtx of.FlattenedContext) of.BoolResolutionDetail { + value, detail := p.resolve(flag, defaultValue, flatCtx, "boolean") + resolved, ok := value.(bool) + if !ok { + resolved = defaultValue + } + return of.BoolResolutionDetail{Value: resolved, ProviderResolutionDetail: detail} +} + +func (p *Provider) StringEvaluation(_ context.Context, flag string, defaultValue string, flatCtx of.FlattenedContext) of.StringResolutionDetail { + value, detail := p.resolve(flag, defaultValue, flatCtx, "string") + resolved, ok := value.(string) + if !ok { + resolved = defaultValue + } + return of.StringResolutionDetail{Value: resolved, ProviderResolutionDetail: detail} +} + +func (p *Provider) FloatEvaluation(_ context.Context, flag string, defaultValue float64, flatCtx of.FlattenedContext) of.FloatResolutionDetail { + value, detail := p.resolve(flag, defaultValue, flatCtx, "number") + resolved, ok := asFloat(value) + if !ok { + resolved = defaultValue + } + return of.FloatResolutionDetail{Value: resolved, ProviderResolutionDetail: detail} +} + +func (p *Provider) IntEvaluation(_ context.Context, flag string, defaultValue int64, flatCtx of.FlattenedContext) of.IntResolutionDetail { + value, detail := p.resolve(flag, defaultValue, flatCtx, "number") + resolved, ok := asInt(value) + if !ok { + resolved = defaultValue + } + return of.IntResolutionDetail{Value: resolved, ProviderResolutionDetail: detail} +} + +func (p *Provider) ObjectEvaluation(_ context.Context, flag string, defaultValue any, flatCtx of.FlattenedContext) of.InterfaceResolutionDetail { + value, detail := p.resolve(flag, defaultValue, flatCtx, "object") + if !isObject(value) { + value = defaultValue + } + return of.InterfaceResolutionDetail{Value: value, ProviderResolutionDetail: detail} +} + +func (p *Provider) resolve(flag string, defaultValue any, flatCtx of.FlattenedContext, expected string) (any, of.ProviderResolutionDetail) { + if p.datafileError != "" { + return defaultValue, errorDetail(of.NewParseErrorResolutionError(p.datafileError)) + } + featureKey, selector := splitKey(flag, p.keySeparator) + context := normalizeContext(flatCtx) + if targetingKey, ok := flatCtx[of.TargetingKey].(string); ok && targetingKey != "" { + context[p.targetingKeyField] = targetingKey + } + + var evaluation featurevisor.Evaluation + var value any + if selector == "" { + if expected != "boolean" { + return defaultValue, typeMismatch(flag, expected) + } + evaluation = p.featurevisor.EvaluateFlag(featureKey, context, featurevisor.OverrideOptions{}) + if evaluation.Enabled != nil { + value = *evaluation.Enabled + } + } else if selector == p.variationKey { + evaluation = p.featurevisor.EvaluateVariation(featureKey, context, featurevisor.OverrideOptions{}) + if evaluation.VariationValue != nil { + value = string(*evaluation.VariationValue) + } else if evaluation.Variation != nil { + value = string(evaluation.Variation.Value) + } + } else { + evaluation = p.featurevisor.EvaluateVariable(featureKey, selector, context, featurevisor.OverrideOptions{}) + value = evaluation.VariableValue + if evaluation.VariableSchema != nil && evaluation.VariableSchema.Type == featurevisor.VariableTypeJSON { + if raw, ok := value.(string); ok { + var parsed any + if json.Unmarshal([]byte(raw), &parsed) == nil { + value = parsed + } + } + } + } + + detail := detailFor(evaluation, p.featurevisor) + if detail.Error() != nil { + return defaultValue, detail + } + if value == nil { + return defaultValue, detail + } + if !matches(value, expected) { + return defaultValue, typeMismatchWithMetadata(flag, expected, detail.FlagMetadata) + } + return value, detail +} + +func detailFor(e featurevisor.Evaluation, fv *featurevisor.Featurevisor) of.ProviderResolutionDetail { + metadata := of.FlagMetadata{"featureKey": string(e.FeatureKey), "featurevisorReason": string(e.Reason), "schemaVersion": fv.GetSchemaVersion()} + if revision := fv.GetRevision(); revision != "" { + metadata["revision"] = revision + } + if e.VariableKey != nil { + metadata["variableKey"] = string(*e.VariableKey) + } + if e.RuleKey != nil { + metadata["ruleKey"] = string(*e.RuleKey) + } + if e.BucketKey != nil { + metadata["bucketKey"] = string(*e.BucketKey) + } + if e.BucketValue != nil { + metadata["bucketValue"] = int(*e.BucketValue) + } + if e.ForceIndex != nil { + metadata["forceIndex"] = *e.ForceIndex + } + if e.VariableOverrideIndex != nil { + metadata["variableOverrideIndex"] = *e.VariableOverrideIndex + } + detail := of.ProviderResolutionDetail{Reason: reasonFor(e.Reason), FlagMetadata: metadata} + if e.VariationValue != nil { + detail.Variant = string(*e.VariationValue) + } else if e.Variation != nil { + detail.Variant = string(e.Variation.Value) + } + switch e.Reason { + case featurevisor.EvaluationReasonFeatureNotFound: + detail.ResolutionError = of.NewFlagNotFoundResolutionError(fmt.Sprintf("Feature %q was not found", e.FeatureKey)) + case featurevisor.EvaluationReasonVariableNotFound: + detail.ResolutionError = of.NewFlagNotFoundResolutionError(fmt.Sprintf("Variable %q was not found for feature %q", valueOrPointer(e.VariableKey), e.FeatureKey)) + case featurevisor.EvaluationReasonNoVariations: + detail.ResolutionError = of.NewFlagNotFoundResolutionError(fmt.Sprintf("Feature %q has no variations", e.FeatureKey)) + case featurevisor.EvaluationReasonError: + detail.ResolutionError = of.NewGeneralResolutionError("Featurevisor evaluation failed", e.Error) + } + return detail +} + +func reasonFor(reason featurevisor.EvaluationReason) of.Reason { + switch reason { + case featurevisor.EvaluationReasonFeatureNotFound, featurevisor.EvaluationReasonVariableNotFound, featurevisor.EvaluationReasonNoVariations, featurevisor.EvaluationReasonError: + return of.ErrorReason + case featurevisor.EvaluationReasonRequired, featurevisor.EvaluationReasonForced, featurevisor.EvaluationReasonSticky, featurevisor.EvaluationReasonRule, featurevisor.EvaluationReasonVariableOverrideVariation, featurevisor.EvaluationReasonVariableOverrideRule: + return of.TargetingMatchReason + case featurevisor.EvaluationReasonAllocated: + return of.SplitReason + case featurevisor.EvaluationReasonDisabled, featurevisor.EvaluationReasonVariationDisabled, featurevisor.EvaluationReasonVariableDisabled: + return of.DisabledReason + default: + return of.DefaultReason + } +} + +func errorDetail(err of.ResolutionError) of.ProviderResolutionDetail { + return of.ProviderResolutionDetail{Reason: of.ErrorReason, ResolutionError: err} +} +func typeMismatch(flag, expected string) of.ProviderResolutionDetail { + return errorDetail(of.NewTypeMismatchResolutionError(fmt.Sprintf("Flag %q did not resolve to a %s value", flag, expected))) +} +func typeMismatchWithMetadata(flag, expected string, metadata of.FlagMetadata) of.ProviderResolutionDetail { + d := typeMismatch(flag, expected) + d.FlagMetadata = metadata + return d +} +func splitKey(key, separator string) (string, string) { + if i := strings.Index(key, separator); i >= 0 { + return key[:i], key[i+len(separator):] + } + return key, "" +} +func valueOr(value, fallback string) string { + if value == "" { + return fallback + } + return value +} +func valueOrPointer(value *string) string { + if value == nil { + return "" + } + return *value +} + +func normalizeContext(input map[string]any) featurevisor.Context { + result := featurevisor.Context{} + for key, value := range input { + result[key] = normalizeValue(value) + } + return result +} +func normalizeValue(value any) any { + if date, ok := value.(time.Time); ok { + return date.Format(time.RFC3339Nano) + } + rv := reflect.ValueOf(value) + if !rv.IsValid() { + return value + } + if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array { + result := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + result[i] = normalizeValue(rv.Index(i).Interface()) + } + return result + } + if values, ok := value.(map[string]any); ok { + return normalizeContext(values) + } + return value +} +func asFloat(value any) (float64, bool) { + switch v := value.(type) { + case float64: + return v, !math.IsNaN(v) && !math.IsInf(v, 0) + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + case json.Number: + n, err := v.Float64() + return n, err == nil + } + return 0, false +} +func asInt(value any) (int64, bool) { + switch v := value.(type) { + case int: + return int64(v), true + case int64: + return v, true + case float64: + return int64(v), math.Trunc(v) == v && !math.IsInf(v, 0) && !math.IsNaN(v) + case json.Number: + n, err := v.Int64() + return n, err == nil + } + return 0, false +} +func isObject(value any) bool { + if value == nil { + return false + } + kind := reflect.TypeOf(value).Kind() + return kind == reflect.Map || kind == reflect.Slice || kind == reflect.Array || kind == reflect.Struct +} +func matches(value any, expected string) bool { + switch expected { + case "boolean": + _, ok := value.(bool) + return ok + case "string": + _, ok := value.(string) + return ok + case "number": + _, ok := asFloat(value) + return ok + case "object": + return isObject(value) + } + return false +} diff --git a/openfeature/provider_test.go b/openfeature/provider_test.go new file mode 100644 index 0000000..0ce3f99 --- /dev/null +++ b/openfeature/provider_test.go @@ -0,0 +1,149 @@ +package openfeature + +import ( + "context" + "testing" + + featurevisor "github.com/featurevisor/featurevisor-go" + of "github.com/open-feature/go-sdk/openfeature" +) + +const testDatafile = `{ + "schemaVersion":"2","revision":"openfeature-test","segments":{}, + "features":{ + "checkout":{ + "bucketBy":"userId", + "variations":[{"value":"on","variables":{"title":"Hello","count":3,"ratio":1.5,"visible":true,"items":["a"],"config":{"color":"blue"},"json":"{\"nested\":true}"}}], + "variablesSchema":{ + "title":{"type":"string","defaultValue":"Default"}, + "count":{"type":"integer","defaultValue":0}, + "ratio":{"type":"double","defaultValue":0}, + "visible":{"type":"boolean","defaultValue":false}, + "items":{"type":"array","defaultValue":[]}, + "config":{"type":"object","defaultValue":{}}, + "json":{"type":"json","defaultValue":"{}"} + }, + "force":[{"conditions":{"attribute":"userId","operator":"equals","value":"forced-user"},"enabled":true,"variation":"on"}], + "traffic":[{"key":"all","segments":"*","percentage":100000,"variation":"on"}] + }, + "empty":{"bucketBy":"userId","variations":[],"traffic":[{"key":"all","segments":"*","percentage":100000,"allocation":[]}]} + } +}` + +func newTestProvider(options ...func(*Options)) *Provider { + level := featurevisor.LogLevelFatal + resolved := Options{FeaturevisorOptions: featurevisor.FeaturevisorOptions{Datafile: testDatafile, LogLevel: &level}} + for _, option := range options { + option(&resolved) + } + return NewProvider(resolved) +} + +func TestProviderResolvesEveryType(t *testing.T) { + p := newTestProvider() + ctx := of.FlattenedContext{of.TargetingKey: "forced-user"} + if result := p.BooleanEvaluation(context.Background(), "checkout", false, ctx); !result.Value || result.Reason != of.TargetingMatchReason { + t.Fatalf("unexpected flag: %#v", result) + } + if result := p.StringEvaluation(context.Background(), "checkout:variation", "fallback", ctx); result.Value != "on" || result.Variant != "on" { + t.Fatalf("unexpected variation: %#v", result) + } + if result := p.StringEvaluation(context.Background(), "checkout:title", "fallback", ctx); result.Value != "Hello" { + t.Fatalf("unexpected string: %#v", result) + } + if result := p.IntEvaluation(context.Background(), "checkout:count", 0, ctx); result.Value != 3 { + t.Fatalf("unexpected integer: %#v", result) + } + if result := p.FloatEvaluation(context.Background(), "checkout:ratio", 0, ctx); result.Value != 1.5 { + t.Fatalf("unexpected float: %#v", result) + } + if result := p.BooleanEvaluation(context.Background(), "checkout:visible", false, ctx); !result.Value { + t.Fatalf("unexpected boolean variable: %#v", result) + } + if result := p.ObjectEvaluation(context.Background(), "checkout:items", []any{}, ctx); len(result.Value.([]any)) != 1 { + t.Fatalf("unexpected array: %#v", result) + } + if result := p.ObjectEvaluation(context.Background(), "checkout:config", map[string]any{}, ctx); result.Value.(map[string]any)["color"] != "blue" { + t.Fatalf("unexpected object: %#v", result) + } + if result := p.ObjectEvaluation(context.Background(), "checkout:json", map[string]any{}, ctx); result.Value.(map[string]any)["nested"] != true { + t.Fatalf("unexpected json: %#v", result) + } +} + +func TestProviderErrorsGrammarTrackingAndLifecycle(t *testing.T) { + tracked := false + p := newTestProvider(func(options *Options) { + options.KeySeparator = "/" + options.VariationKey = "$variation" + options.OnTrack = func(event TrackingEvent) { tracked = event.Name == "purchase" } + }) + ctx := of.FlattenedContext{of.TargetingKey: "user"} + if result := p.StringEvaluation(context.Background(), "checkout/$variation", "fallback", ctx); result.Value != "on" { + t.Fatalf("custom grammar failed: %#v", result) + } + if result := p.StringEvaluation(context.Background(), "missing", "fallback", ctx); result.ResolutionDetail().ErrorCode != of.TypeMismatchCode { + t.Fatalf("expected type mismatch: %#v", result) + } + if result := p.BooleanEvaluation(context.Background(), "missing", true, ctx); result.ResolutionDetail().ErrorCode != of.FlagNotFoundCode || result.Value != true { + t.Fatalf("expected missing fallback: %#v", result) + } + if result := p.StringEvaluation(context.Background(), "empty/$variation", "fallback", ctx); result.ResolutionDetail().ErrorCode != of.FlagNotFoundCode { + t.Fatalf("expected no variations error: %#v", result) + } + p.Track(context.Background(), "purchase", of.NewEvaluationContext("user", nil), of.NewTrackingEventDetails(1)) + if !tracked { + t.Fatal("tracking callback was not called") + } + p.Shutdown() +} + +func TestProviderWorksThroughOpenFeatureSDK(t *testing.T) { + p := newTestProvider() + if err := of.SetProviderAndWait(p); err != nil { + t.Fatal(err) + } + client := of.NewClient("") + value, err := client.BooleanValue(context.Background(), "checkout", false, of.NewEvaluationContext("forced-user", nil)) + if err != nil || !value { + t.Fatalf("unexpected OpenFeature result: %v %v", value, err) + } +} + +func TestMalformedDatafileReportsParseError(t *testing.T) { + level := featurevisor.LogLevelFatal + p := NewProvider(Options{FeaturevisorOptions: featurevisor.FeaturevisorOptions{Datafile: "{", LogLevel: &level}}) + result := p.BooleanEvaluation(context.Background(), "checkout", false, nil) + if result.ResolutionDetail().ErrorCode != of.ParseErrorCode { + t.Fatalf("expected parse error: %#v", result) + } + p.Featurevisor().SetDatafile(testDatafile, true) + if recovered := p.BooleanEvaluation(context.Background(), "checkout", false, of.FlattenedContext{of.TargetingKey: "forced-user"}); !recovered.Value || recovered.Error() != nil { + t.Fatalf("expected provider to recover after valid datafile: %#v", recovered) + } +} + +func TestProviderBorrowsExistingFeaturevisor(t *testing.T) { + closed := false + level := featurevisor.LogLevelFatal + fv := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ + Datafile: testDatafile, + LogLevel: &level, + Modules: []*featurevisor.FeaturevisorModule{{ + Name: "owner", + Close: func() { closed = true }, + }}, + }) + p := NewProvider(Options{Featurevisor: fv}) + if p.Featurevisor() != fv { + t.Fatal("provider did not reuse the supplied Featurevisor instance") + } + p.Shutdown() + if closed { + t.Fatal("provider closed a caller-owned Featurevisor instance") + } + fv.Close() + if !closed { + t.Fatal("caller could not close its Featurevisor instance") + } +}