diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ffd65d4..e721a85 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -3,40 +3,86 @@ name: Checks on: push: branches: ["**"] + pull_request: + +permissions: + contents: read jobs: - ci: + sdk: + name: SDK (Go ${{ matrix.go }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go: ["1.21.x", "1.25.x"] steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: "1.22" + go-version: ${{ matrix.go }} + cache-dependency-path: go.sum - - name: Install dependencies - run: go mod tidy + - name: Test SDK + run: go test -race ./... - - name: Build + - name: Build CLI run: make build - - name: Test - run: make test + openfeature: + name: OpenFeature provider + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: "1.25.x" + cache-dependency-path: | + go.sum + openfeature/go.sum + + - name: Test provider + run: make test-openfeature + + - name: Verify module boundaries + run: make verify-packages + + example: + name: Featurevisor example-1 + runs-on: ubuntu-latest - - uses: actions/setup-node@v4 + steps: + - uses: actions/checkout@v7 with: - node-version-file: ".nvmrc" + path: featurevisor-go - - name: Setup Featurevisor example-1 project + - uses: actions/checkout@v7 + with: + repository: featurevisor/featurevisor + ref: main + path: featurevisor + + - uses: actions/setup-go@v6 + with: + go-version: "1.25.x" + cache-dependency-path: featurevisor-go/go.sum + + - uses: actions/setup-node@v7 + with: + node-version-file: "featurevisor/.nvmrc" + cache: npm + cache-dependency-path: featurevisor/package-lock.json + + - name: Build Featurevisor CLI + working-directory: featurevisor run: | - mkdir example-1 - (cd example-1 && npx @featurevisor/cli@2.x init --example=1) - (cd example-1 && npm install) - (cd example-1 && npx featurevisor build) - (cd example-1 && npx featurevisor test) - - - name: Run Featurevisor project tests against Go SDK - run: go run ./cmd/main.go test --projectDirectoryPath=./example-1 + npm ci + npm run build + + - name: Run example-1 through Go SDK + working-directory: featurevisor-go + run: go run ./cmd/main.go test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4f0d50b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release validation + +on: + push: + tags: + - "v*" + - "openfeature/v*" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: "1.25.x" + cache-dependency-path: | + go.sum + openfeature/go.sum + + - name: Validate tag and modules + shell: bash + run: | + if [[ "$GITHUB_REF_NAME" == openfeature/* ]]; then + [[ "$GITHUB_REF_NAME" =~ ^openfeature/v2\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] + else + [[ "$GITHUB_REF_NAME" =~ ^v2\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] + fi + make verify-packages + + - name: Test SDK and provider + run: make test diff --git a/LICENSE b/LICENSE index d9fbfa5..434d943 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2025 Fahad Heylaal (https://fahad19.com) +Copyright (c) 2026 Fahad Heylaal (https://fahad19.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 79c9e25..57ed6da 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 verify-packages clean setup-monorepo update-monorepo build: mkdir -p build @@ -6,11 +6,20 @@ 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 +verify-packages: + test "$$(go list -m)" = "github.com/featurevisor/featurevisor-go/v2" + test "$$(cd openfeature && GOWORK=off go list -m)" = "github.com/featurevisor/featurevisor-go/openfeature/v2" + (cd openfeature && GOWORK=off go list -deps ./... >/dev/null) + clean: rm -rf build diff --git a/README.md b/README.md index 20efb24..8194fb7 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) @@ -62,7 +63,7 @@ See example application [here](https://github.com/featurevisor/featurevisor-exam In your Go application, install the SDK using Go modules: ```bash -go get github.com/featurevisor/featurevisor-go +go get github.com/featurevisor/featurevisor-go/v2 ``` ## Public API @@ -77,6 +78,8 @@ f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ Most applications only need `CreateFeaturevisor`, the `Featurevisor` instance type, and `FeaturevisorOptions`. Public extension and observability types include `FeaturevisorModule`, `FeaturevisorDiagnostic`, and the datafile model types. +Concurrent evaluations are safe after an instance is configured. Do not call state-changing methods such as `SetDatafile`, `SetContext`, `SetSticky`, `AddModule`, `RemoveModule`, or `Close` concurrently with evaluations or with each other. Apply those changes from a serialized update path. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. + ## Initialization The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: @@ -88,7 +91,7 @@ import ( "io" "net/http" - "github.com/featurevisor/featurevisor-go" + "github.com/featurevisor/featurevisor-go/v2" ) func main() { @@ -150,7 +153,7 @@ You can set context at the time of initialization: ```go import ( - "github.com/featurevisor/featurevisor-go" + "github.com/featurevisor/featurevisor-go/v2" ) f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ @@ -335,7 +338,7 @@ Sticky values belong to an SDK or child instance. Evaluation options do not acce ```go import ( - "github.com/featurevisor/featurevisor-go" + "github.com/featurevisor/featurevisor-go/v2" ) f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ @@ -462,7 +465,7 @@ import ( "io" "net/http" - "github.com/featurevisor/featurevisor-go" + "github.com/featurevisor/featurevisor-go/v2" ) func updateDatafile(f *featurevisor.Featurevisor, datafileURL string) { @@ -643,7 +646,7 @@ If `Setup` panics, the module is not registered. Featurevisor removes subscripti ```go import ( - "github.com/featurevisor/featurevisor-go" + "github.com/featurevisor/featurevisor-go/v2" ) myCustomModule := &featurevisor.FeaturevisorModule{ @@ -702,7 +705,7 @@ You can register modules at the time of SDK initialization: ```go import ( - "github.com/featurevisor/featurevisor-go" + "github.com/featurevisor/featurevisor-go/v2" ) f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ @@ -721,6 +724,8 @@ removeModule() ## Child instance +A child snapshots the parent keys that exist when it is spawned. Child values win for those keys. Parent keys introduced later are still inherited. Calling `Close()` removes both child-owned listeners and subscriptions delegated to the parent. + When dealing with purely client-side applications, it is understandable that there is only one user involved, like in browser or mobile applications. But when using Featurevisor SDK in server-side applications, where a single server instance can handle multiple user requests simultaneously, it is important to isolate the context for each request. @@ -746,8 +751,11 @@ Similar to parent SDK, child instances also support several additional methods: - `SetContext` - `SetSticky` +- `EvaluateFlag` - `IsEnabled` +- `EvaluateVariation` - `GetVariation` +- `EvaluateVariable` - `GetVariable` - `GetVariableBoolean` - `GetVariableString` @@ -798,12 +806,12 @@ go run cmd/main.go test \ If you want to validate parity locally against the JavaScript SDK runner, you can use the bundled example project: ```bash -cd /Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 +cd ../featurevisor/examples/example-1 npx featurevisor test # from this Go SDK repository root: go run cmd/main.go test \ - --projectDirectoryPath="/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1" \ + --projectDirectoryPath="../featurevisor/examples/example-1" \ --onlyFailures # or: @@ -839,6 +847,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/v2 +``` + +```go +import ( + "context" + + featurevisor "github.com/featurevisor/featurevisor-go/v2" + featurevisorof "github.com/featurevisor/featurevisor-go/openfeature/v2" + 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 @@ -859,8 +922,10 @@ go test ./... ### Releasing -- Manually create a new release on [GitHub](https://github.com/featurevisor/featurevisor-go/releases) -- Tag it with a prefix of `v`, like `v1.0.0` +- Tag the core SDK as `v2.x.y`. +- Tag the provider module separately as `openfeature/v2.x.y`. +- Run `make verify-packages` before creating either release. +- Create the matching releases on [GitHub](https://github.com/featurevisor/featurevisor-go/releases). ## License diff --git a/api_compatibility_test.go b/api_compatibility_test.go index 897fe5c..4294fe1 100644 --- a/api_compatibility_test.go +++ b/api_compatibility_test.go @@ -104,6 +104,21 @@ func TestAPICompatibilityWithStringFeatureKey(t *testing.T) { if childVariable == nil { t.Error("Expected child variable to be returned") } + + flagEvaluation := child.EvaluateFlag("test-feature") + if flagEvaluation.Enabled == nil || !*flagEvaluation.Enabled { + t.Fatalf("expected child flag evaluation to be enabled, got %#v", flagEvaluation) + } + + variationEvaluation := child.EvaluateVariation("test-feature") + if variationEvaluation.VariationValue == nil || string(*variationEvaluation.VariationValue) != *childVariation { + t.Fatalf("expected child variation evaluation to match getter, got %#v", variationEvaluation) + } + + variableEvaluation := child.EvaluateVariable("test-feature", "color") + if variableEvaluation.VariableValue != "blue" { + t.Fatalf("expected child variable evaluation value blue, got %#v", variableEvaluation) + } } func TestAPICompatibilityWithNonExistentFeature(t *testing.T) { diff --git a/bucketer.go b/bucketer.go index c1e7e2b..2fe358b 100644 --- a/bucketer.go +++ b/bucketer.go @@ -2,12 +2,15 @@ package featurevisor import ( "fmt" + "math" + "reflect" + "strconv" "strings" ) const ( - HASH_SEED = 1 - MAX_HASH_VALUE = 1 << 32 + hashSeed = 1 + maxHashValue = 1 << 32 MAX_BUCKETED_NUMBER = 100000 // 100% * 1000 to include three decimal places in the same integer value ) @@ -17,31 +20,31 @@ type BucketKey = string // BucketValue represents a bucket value (0 to 100,000) type BucketValue = int -// GetBucketKeyOptions contains options for getting a bucket key -type GetBucketKeyOptions struct { +// getBucketKeyOptions contains options for getting a bucket key +type getBucketKeyOptions struct { FeatureKey FeatureKey BucketBy BucketBy Context Context - featurevisorLogger *featurevisorLogger + diagnosticReporter *diagnosticReporter } -// DEFAULT_BUCKET_KEY_SEPARATOR is the default separator for bucket keys -const DEFAULT_BUCKET_KEY_SEPARATOR = "." +// defaultBucketKeySeparator is the default separator for bucket keys +const defaultBucketKeySeparator = "." -// GetBucketedNumber returns a bucketed number for a given bucket key -func GetBucketedNumber(bucketKey string) BucketValue { - hashValue := MurmurHashV3(bucketKey, HASH_SEED) - ratio := float64(hashValue) / float64(MAX_HASH_VALUE) +// getBucketedNumber returns a bucketed number for a given bucket key +func getBucketedNumber(bucketKey string) BucketValue { + hashValue := murmurHashV3(bucketKey, hashSeed) + ratio := float64(hashValue) / float64(maxHashValue) return int(ratio * float64(MAX_BUCKETED_NUMBER)) } -// GetBucketKey returns a bucket key based on the feature key, bucket by configuration, and context -func GetBucketKey(options GetBucketKeyOptions) BucketKey { +// getBucketKey returns a bucket key based on the feature key, bucket by configuration, and context +func getBucketKey(options getBucketKeyOptions) BucketKey { featureKey := options.FeatureKey bucketBy := options.BucketBy context := options.Context - logger := options.featurevisorLogger + diagnostics := options.diagnosticReporter var bucketType string var attributeKeys []string @@ -90,7 +93,7 @@ func GetBucketKey(options GetBucketKeyOptions) BucketKey { } } default: - logger.Error("invalid bucketBy", logDetails{ + diagnostics.Error("invalid bucketBy", logDetails{ "featureKey": featureKey, "bucketBy": bucketBy, }) @@ -101,9 +104,9 @@ func GetBucketKey(options GetBucketKeyOptions) BucketKey { // Process each attribute key for _, attributeKey := range attributeKeys { - attributeValue := GetValueFromContext(context, attributeKey) + attributeValue := getValueFromContext(context, attributeKey) - if attributeValue == nil { + if attributeValue == nil && !pathExists(context, attributeKey) { continue } @@ -126,7 +129,7 @@ func GetBucketKey(options GetBucketKeyOptions) BucketKey { bucketKeyStrings[i] = toString(value) } - result := strings.Join(bucketKeyStrings, DEFAULT_BUCKET_KEY_SEPARATOR) + result := strings.Join(bucketKeyStrings, defaultBucketKeySeparator) return result } @@ -139,14 +142,55 @@ func toString(value interface{}) string { case int: return fmt.Sprintf("%d", v) case float64: - return fmt.Sprintf("%.0f", v) + return javascriptFloat(v, 64) + case float32: + return javascriptFloat(float64(v), 32) case bool: if v { return "true" } return "false" + case nil: + return "" default: - // For other types, try to convert to string + valueOf := reflect.ValueOf(value) + if valueOf.IsValid() && (valueOf.Kind() == reflect.Slice || valueOf.Kind() == reflect.Array) { + parts := make([]string, valueOf.Len()) + for index := 0; index < valueOf.Len(); index++ { + parts[index] = toString(valueOf.Index(index).Interface()) + } + return strings.Join(parts, ",") + } + if valueOf.IsValid() && valueOf.Kind() == reflect.Map { + return "[object Object]" + } return fmt.Sprintf("%v", v) } } + +func javascriptFloat(value float64, bitSize int) string { + if math.IsNaN(value) { + return "NaN" + } + if math.IsInf(value, 1) { + return "Infinity" + } + if math.IsInf(value, -1) { + return "-Infinity" + } + if value == 0 { + return "0" + } + + absolute := math.Abs(value) + format := byte('f') + if absolute >= 1e21 || absolute < 1e-6 { + format = 'g' + } + result := strconv.FormatFloat(value, format, -1, bitSize) + if exponentIndex := strings.IndexByte(result, 'e'); exponentIndex != -1 { + exponent, _ := strconv.Atoi(result[exponentIndex+1:]) + return fmt.Sprintf("%se%+d", result[:exponentIndex], exponent) + } + return result +} diff --git a/bucketer_test.go b/bucketer_test.go index f3d5159..5998e78 100644 --- a/bucketer_test.go +++ b/bucketer_test.go @@ -10,13 +10,13 @@ func TestGetBucketedNumber(t *testing.T) { keys := []string{"foo", "bar", "baz", "123adshlk348-93asdlk"} for _, key := range keys { - n := GetBucketedNumber(key) + n := getBucketedNumber(key) if n < 0 { - t.Errorf("GetBucketedNumber(%s) = %d; want >= 0", key, n) + t.Errorf("getBucketedNumber(%s) = %d; want >= 0", key, n) } if n > MAX_BUCKETED_NUMBER { - t.Errorf("GetBucketedNumber(%s) = %d; want <= %d", key, n, MAX_BUCKETED_NUMBER) + t.Errorf("getBucketedNumber(%s) = %d; want <= %d", key, n, MAX_BUCKETED_NUMBER) } } }) @@ -32,17 +32,29 @@ func TestGetBucketedNumber(t *testing.T) { } for key, expected := range expectedResults { - n := GetBucketedNumber(key) + n := getBucketedNumber(key) if n != expected { - t.Errorf("GetBucketedNumber(%s) = %d; want %d", key, n, expected) + t.Errorf("getBucketedNumber(%s) = %d; want %d", key, n, expected) } } }) } func TestGetBucketKey(t *testing.T) { - logger := newLogger(loggerOptions{}) + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) + + t.Run("stringifies whole floating point values and negative zero like JavaScript", func(t *testing.T) { + bucketKey := getBucketKey(getBucketKeyOptions{ + FeatureKey: "feature", + BucketBy: []string{"whole", "negativeZero", "small", "large"}, + Context: Context{"whole": 1.0, "negativeZero": -0.0, "small": 1e-6, "large": 1e21}, + diagnosticReporter: diagnostics, + }) + if bucketKey != "1.0.0.000001.1e+21.feature" { + t.Fatalf("getBucketKey() = %s; want JavaScript number formatting", bucketKey) + } + }) t.Run("plain: should return a bucket key for a plain bucketBy", func(t *testing.T) { featureKey := FeatureKey("test-feature") @@ -52,16 +64,16 @@ func TestGetBucketKey(t *testing.T) { "browser": "chrome", } - bucketKey := GetBucketKey(GetBucketKeyOptions{ + bucketKey := getBucketKey(getBucketKeyOptions{ FeatureKey: featureKey, BucketBy: bucketBy, Context: context, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) expected := "123.test-feature" if bucketKey != expected { - t.Errorf("GetBucketKey() = %s; want %s", bucketKey, expected) + t.Errorf("getBucketKey() = %s; want %s", bucketKey, expected) } }) @@ -72,16 +84,16 @@ func TestGetBucketKey(t *testing.T) { "browser": "chrome", } - bucketKey := GetBucketKey(GetBucketKeyOptions{ + bucketKey := getBucketKey(getBucketKeyOptions{ FeatureKey: featureKey, BucketBy: bucketBy, Context: context, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) expected := "test-feature" if bucketKey != expected { - t.Errorf("GetBucketKey() = %s; want %s", bucketKey, expected) + t.Errorf("getBucketKey() = %s; want %s", bucketKey, expected) } }) @@ -94,16 +106,16 @@ func TestGetBucketKey(t *testing.T) { "browser": "chrome", } - bucketKey := GetBucketKey(GetBucketKeyOptions{ + bucketKey := getBucketKey(getBucketKeyOptions{ FeatureKey: featureKey, BucketBy: bucketBy, Context: context, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) expected := "123.234.test-feature" if bucketKey != expected { - t.Errorf("GetBucketKey() = %s; want %s", bucketKey, expected) + t.Errorf("getBucketKey() = %s; want %s", bucketKey, expected) } }) @@ -115,16 +127,16 @@ func TestGetBucketKey(t *testing.T) { "browser": "chrome", } - bucketKey := GetBucketKey(GetBucketKeyOptions{ + bucketKey := getBucketKey(getBucketKeyOptions{ FeatureKey: featureKey, BucketBy: bucketBy, Context: context, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) expected := "123.test-feature" if bucketKey != expected { - t.Errorf("GetBucketKey() = %s; want %s", bucketKey, expected) + t.Errorf("getBucketKey() = %s; want %s", bucketKey, expected) } }) @@ -139,16 +151,16 @@ func TestGetBucketKey(t *testing.T) { "browser": "chrome", } - bucketKey := GetBucketKey(GetBucketKeyOptions{ + bucketKey := getBucketKey(getBucketKeyOptions{ FeatureKey: featureKey, BucketBy: bucketBy, Context: context, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) expected := "123.234.test-feature" if bucketKey != expected { - t.Errorf("GetBucketKey() = %s; want %s", bucketKey, expected) + t.Errorf("getBucketKey() = %s; want %s", bucketKey, expected) } }) @@ -163,16 +175,16 @@ func TestGetBucketKey(t *testing.T) { "browser": "chrome", } - bucketKey := GetBucketKey(GetBucketKeyOptions{ + bucketKey := getBucketKey(getBucketKeyOptions{ FeatureKey: featureKey, BucketBy: bucketBy, Context: context, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) expected := "234.test-feature" if bucketKey != expected { - t.Errorf("GetBucketKey() = %s; want %s", bucketKey, expected) + t.Errorf("getBucketKey() = %s; want %s", bucketKey, expected) } }) @@ -186,16 +198,16 @@ func TestGetBucketKey(t *testing.T) { "browser": "chrome", } - bucketKey := GetBucketKey(GetBucketKeyOptions{ + bucketKey := getBucketKey(getBucketKeyOptions{ FeatureKey: featureKey, BucketBy: bucketBy, Context: context, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) expected := "deviceIdHere.test-feature" if bucketKey != expected { - t.Errorf("GetBucketKey() = %s; want %s", bucketKey, expected) + t.Errorf("getBucketKey() = %s; want %s", bucketKey, expected) } }) @@ -207,15 +219,15 @@ func TestGetBucketKey(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Error("GetBucketKey should panic with invalid bucketBy") + t.Error("getBucketKey should panic with invalid bucketBy") } }() - GetBucketKey(GetBucketKeyOptions{ + getBucketKey(getBucketKeyOptions{ FeatureKey: featureKey, BucketBy: bucketBy, Context: context, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) }) } @@ -239,7 +251,7 @@ func TestToString(t *testing.T) { { name: "float64", input: 123.456, - expected: "123", + expected: "123.456", }, { name: "bool true", @@ -254,7 +266,7 @@ func TestToString(t *testing.T) { { name: "nil", input: nil, - expected: "", + expected: "", }, } diff --git a/child.go b/child.go index d0b4ae4..9499a53 100644 --- a/child.go +++ b/child.go @@ -2,23 +2,30 @@ package featurevisor import "fmt" -// ChildOptions contains options for creating a child instance -type ChildOptions struct { +// childOptions contains options for creating a child instance +type childOptions struct { Parent *Featurevisor Context Context Sticky *StickyFeatures } +type childParentSubscription struct { + id uint64 + unsubscribe Unsubscribe +} + // FeaturevisorChild represents a child Featurevisor instance type FeaturevisorChild struct { - parent *Featurevisor - context Context - sticky *StickyFeatures - emitter *emitter + parent *Featurevisor + context Context + sticky *StickyFeatures + emitter *emitter + parentSubscriptions []childParentSubscription + nextSubscriptionID uint64 } // newFeaturevisorChild creates a new child instance. -func newFeaturevisorChild(options ChildOptions) *FeaturevisorChild { +func newFeaturevisorChild(options childOptions) *FeaturevisorChild { return &FeaturevisorChild{ parent: options.Parent, context: options.Context, @@ -33,11 +40,37 @@ func (c *FeaturevisorChild) On(eventName EventName, callback EventCallback) Unsu return c.emitter.On(eventName, callback) } - return c.parent.On(eventName, callback) + parentUnsubscribe := c.parent.On(eventName, callback) + active := true + c.nextSubscriptionID++ + subscriptionID := c.nextSubscriptionID + var unsubscribe Unsubscribe + unsubscribe = func() { + if !active { + return + } + active = false + parentUnsubscribe() + for index, subscription := range c.parentSubscriptions { + if subscription.id == subscriptionID { + c.parentSubscriptions = append(c.parentSubscriptions[:index], c.parentSubscriptions[index+1:]...) + break + } + } + } + c.parentSubscriptions = append(c.parentSubscriptions, childParentSubscription{ + id: subscriptionID, + unsubscribe: unsubscribe, + }) + return unsubscribe } // Close closes child instance listeners func (c *FeaturevisorChild) Close() { + for _, subscription := range append([]childParentSubscription{}, c.parentSubscriptions...) { + subscription.unsubscribe() + } + c.parentSubscriptions = nil c.emitter.ClearAll() } @@ -108,7 +141,7 @@ func (c *FeaturevisorChild) SetSticky(sticky StickyFeatures, replace ...bool) { } // getEvaluationDependencies gets evaluation dependencies -func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options OverrideOptions) EvaluateDependencies { +func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options OverrideOptions) evaluateDependencies { var sticky *StickyFeatures if options.sticky != nil { sticky = options.sticky @@ -116,33 +149,49 @@ func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options O sticky = c.sticky } - return EvaluateDependencies{ - Context: c.GetContext(context), - featurevisorLogger: c.parent.logger, - modulesManager: c.parent.modulesManager, - datafileReader: c.parent.datafileReader, - sticky: sticky, - DefaultVariationValue: options.DefaultVariationValue, - DefaultVariableValue: options.DefaultVariableValue, + return evaluateDependencies{ + Context: c.GetContext(context), + diagnosticReporter: c.parent.diagnostics, + modulesManager: c.parent.modulesManager, + instanceEvaluationDataProvider: c.parent.instanceEvaluationDataProvider, + sticky: sticky, + DefaultVariationValue: options.DefaultVariationValue, + DefaultVariableValue: options.DefaultVariableValue, + DefaultVariableValueSet: options.DefaultVariableValueSet, } } -// EvaluateFlag evaluates a feature flag -func (c *FeaturevisorChild) EvaluateFlag(featureKey string, context Context, options OverrideOptions) Evaluation { - return EvaluateWithModules(EvaluateOptions{ - EvaluateParams: EvaluateParams{ +func (c *FeaturevisorChild) evaluateFlag(featureKey string, context Context, options OverrideOptions) Evaluation { + return evaluateWithModules(EvaluateOptions{ + evaluateParams: evaluateParams{ Type: EvaluationTypeFlag, FeatureKey: FeatureKey(featureKey), }, - EvaluateDependencies: c.getEvaluationDependencies(context, options), + evaluateDependencies: c.getEvaluationDependencies(context, options), }) } +// EvaluateFlag evaluates a feature flag and returns its full evaluation details. +func (c *FeaturevisorChild) EvaluateFlag(featureKey string, args ...interface{}) Evaluation { + contextValue := Context{} + optionsValue := OverrideOptions{} + for _, arg := range args { + switch v := arg.(type) { + case Context: + contextValue = v + case OverrideOptions: + optionsValue = v + } + } + + return c.evaluateFlag(featureKey, contextValue, optionsValue) +} + // IsEnabled checks if a feature is enabled func (c *FeaturevisorChild) IsEnabled(featureKey string, args ...interface{}) bool { defer func() { if r := recover(); r != nil { - c.parent.logger.Error("isEnabled", logDetails{ + c.parent.diagnostics.Error("isEnabled", logDetails{ "featureKey": featureKey, "error": r, }) @@ -163,7 +212,7 @@ func (c *FeaturevisorChild) IsEnabled(featureKey string, args ...interface{}) bo } } - evaluation := c.EvaluateFlag(featureKey, contextValue, optionsValue) + evaluation := c.evaluateFlag(featureKey, contextValue, optionsValue) if evaluation.Enabled != nil { return *evaluation.Enabled @@ -172,22 +221,37 @@ func (c *FeaturevisorChild) IsEnabled(featureKey string, args ...interface{}) bo return false } -// EvaluateVariation evaluates a feature variation -func (c *FeaturevisorChild) EvaluateVariation(featureKey string, context Context, options OverrideOptions) Evaluation { - return EvaluateWithModules(EvaluateOptions{ - EvaluateParams: EvaluateParams{ +func (c *FeaturevisorChild) evaluateVariation(featureKey string, context Context, options OverrideOptions) Evaluation { + return evaluateWithModules(EvaluateOptions{ + evaluateParams: evaluateParams{ Type: EvaluationTypeVariation, FeatureKey: FeatureKey(featureKey), }, - EvaluateDependencies: c.getEvaluationDependencies(context, options), + evaluateDependencies: c.getEvaluationDependencies(context, options), }) } +// EvaluateVariation evaluates a variation and returns its full evaluation details. +func (c *FeaturevisorChild) EvaluateVariation(featureKey string, args ...interface{}) Evaluation { + contextValue := Context{} + optionsValue := OverrideOptions{} + for _, arg := range args { + switch v := arg.(type) { + case Context: + contextValue = v + case OverrideOptions: + optionsValue = v + } + } + + return c.evaluateVariation(featureKey, contextValue, optionsValue) +} + // GetVariation gets a feature variation func (c *FeaturevisorChild) GetVariation(featureKey string, args ...interface{}) *string { defer func() { if r := recover(); r != nil { - c.parent.logger.Error("getVariation", logDetails{ + c.parent.diagnostics.Error("getVariation", logDetails{ "featureKey": featureKey, "error": r, }) @@ -208,7 +272,7 @@ func (c *FeaturevisorChild) GetVariation(featureKey string, args ...interface{}) } } - evaluation := c.EvaluateVariation(featureKey, contextValue, optionsValue) + evaluation := c.evaluateVariation(featureKey, contextValue, optionsValue) if evaluation.VariationValue != nil { // VariationValue is already a string type alias @@ -225,23 +289,38 @@ func (c *FeaturevisorChild) GetVariation(featureKey string, args ...interface{}) return nil } -// EvaluateVariable evaluates a feature variable -func (c *FeaturevisorChild) EvaluateVariable(featureKey string, variableKey VariableKey, context Context, options OverrideOptions) Evaluation { - return EvaluateWithModules(EvaluateOptions{ - EvaluateParams: EvaluateParams{ +func (c *FeaturevisorChild) evaluateVariable(featureKey string, variableKey VariableKey, context Context, options OverrideOptions) Evaluation { + return evaluateWithModules(EvaluateOptions{ + evaluateParams: evaluateParams{ Type: EvaluationTypeVariable, FeatureKey: FeatureKey(featureKey), VariableKey: &variableKey, }, - EvaluateDependencies: c.getEvaluationDependencies(context, options), + evaluateDependencies: c.getEvaluationDependencies(context, options), }) } +// EvaluateVariable evaluates a variable and returns its full evaluation details. +func (c *FeaturevisorChild) EvaluateVariable(featureKey string, variableKey string, args ...interface{}) Evaluation { + contextValue := Context{} + optionsValue := OverrideOptions{} + for _, arg := range args { + switch v := arg.(type) { + case Context: + contextValue = v + case OverrideOptions: + optionsValue = v + } + } + + return c.evaluateVariable(featureKey, VariableKey(variableKey), contextValue, optionsValue) +} + // GetVariable gets a feature variable func (c *FeaturevisorChild) GetVariable(featureKey string, variableKey string, args ...interface{}) VariableValue { defer func() { if r := recover(); r != nil { - c.parent.logger.Error("getVariable", logDetails{ + c.parent.diagnostics.Error("getVariable", logDetails{ "featureKey": featureKey, "variableKey": variableKey, "error": r, @@ -263,7 +342,7 @@ func (c *FeaturevisorChild) GetVariable(featureKey string, variableKey string, a } } - evaluation := c.EvaluateVariable(featureKey, VariableKey(variableKey), contextValue, optionsValue) + evaluation := c.evaluateVariable(featureKey, VariableKey(variableKey), contextValue, optionsValue) if evaluation.VariableValue != nil { return evaluation.VariableValue @@ -279,7 +358,7 @@ func (c *FeaturevisorChild) GetVariableBoolean(featureKey string, variableKey st return nil } - typedValue := GetValueByType(value, "boolean") + typedValue := getValueByType(value, "boolean") if boolValue, ok := typedValue.(bool); ok { return &boolValue } @@ -294,7 +373,7 @@ func (c *FeaturevisorChild) GetVariableString(featureKey string, variableKey str return nil } - typedValue := GetValueByType(value, "string") + typedValue := getValueByType(value, "string") if stringValue, ok := typedValue.(string); ok { return &stringValue } @@ -309,7 +388,7 @@ func (c *FeaturevisorChild) GetVariableInteger(featureKey string, variableKey st return nil } - typedValue := GetValueByType(value, "integer") + typedValue := getValueByType(value, "integer") if intValue, ok := typedValue.(int); ok { return &intValue } @@ -324,7 +403,7 @@ func (c *FeaturevisorChild) GetVariableDouble(featureKey string, variableKey str return nil } - typedValue := GetValueByType(value, "double") + typedValue := getValueByType(value, "double") if floatValue, ok := typedValue.(float64); ok { return &floatValue } @@ -339,7 +418,7 @@ func (c *FeaturevisorChild) GetVariableArray(featureKey string, variableKey stri return nil } - return ToTypedArray[string](GetValueByType(value, "array")) + return toTypedArray[string](getValueByType(value, "array")) } // GetVariableObject gets an object variable @@ -349,7 +428,7 @@ func (c *FeaturevisorChild) GetVariableObject(featureKey string, variableKey str return nil } - typedValue := ToTypedObject[map[string]interface{}](GetValueByType(value, "object")) + typedValue := toTypedObject[map[string]interface{}](getValueByType(value, "object")) if typedValue == nil { return nil } @@ -380,7 +459,7 @@ func (c *FeaturevisorChild) GetVariableArrayInto(featureKey string, variableKey return decodeInto(nil, out) } - arrayValue := GetValueByType(value, "array") + arrayValue := getValueByType(value, "array") if arrayValue == nil { return fmt.Errorf("variable %q is not an array", variableKey) } @@ -401,7 +480,7 @@ func (c *FeaturevisorChild) GetVariableObjectInto(featureKey string, variableKey return decodeInto(nil, out) } - objectValue := GetValueByType(value, "object") + objectValue := getValueByType(value, "object") if objectValue == nil { return fmt.Errorf("variable %q is not an object", variableKey) } @@ -416,7 +495,7 @@ func (c *FeaturevisorChild) GetAllEvaluations(context Context, featureKeys []str keys := featureKeys if len(keys) == 0 { // Get all feature keys from parent - allKeys := c.parent.datafileReader.GetFeatureKeys() + allKeys := c.parent.instanceEvaluationDataProvider.GetFeatureKeys() keys = make([]string, len(allKeys)) for j, key := range allKeys { keys[j] = string(key) @@ -430,7 +509,7 @@ func (c *FeaturevisorChild) GetAllEvaluations(context Context, featureKeys []str } // variation - if c.parent.datafileReader.HasVariations(FeatureKey(featureKey)) { + if c.parent.instanceEvaluationDataProvider.HasVariations(FeatureKey(featureKey)) { variation := c.GetVariation(featureKey, context, options) if variation != nil { evaluatedFeature.Variation = variation @@ -438,7 +517,7 @@ func (c *FeaturevisorChild) GetAllEvaluations(context Context, featureKeys []str } // variables - variableKeys := c.parent.datafileReader.GetVariableKeys(FeatureKey(featureKey)) + variableKeys := c.parent.instanceEvaluationDataProvider.GetVariableKeys(FeatureKey(featureKey)) if len(variableKeys) > 0 { evaluatedFeature.Variables = make(map[VariableKey]VariableValue) for _, variableKey := range variableKeys { diff --git a/child_parity_test.go b/child_parity_test.go index 3477f64..da665e3 100644 --- a/child_parity_test.go +++ b/child_parity_test.go @@ -52,7 +52,29 @@ func TestChildEventIsolationAndProxying(t *testing.T) { child.Close() child.SetContext(Context{"country": "nl"}) + instance.SetDatafile(DatafileContent{ + SchemaVersion: "2", + Revision: "3", + Features: map[FeatureKey]Feature{}, + Segments: map[SegmentKey]Segment{}, + }) if childContextEvents != 1 { t.Fatalf("expected child listeners to be cleared after close, got %d", childContextEvents) } + if childDatafileEvents != 1 { + t.Fatalf("expected delegated listeners to be cleared after close, got %d", childDatafileEvents) + } +} + +func TestChildContextMatchesJavaScriptSnapshotBehavior(t *testing.T) { + instance := CreateFeaturevisor(FeaturevisorOptions{ + Context: Context{"country": "nl", "plan": "free"}, + }) + child := instance.Spawn(Context{"country": "de"}) + instance.SetContext(Context{"plan": "pro", "locale": "de-DE"}) + + actual := child.GetContext(nil) + if actual["country"] != "de" || actual["plan"] != "free" || actual["locale"] != "de-DE" { + t.Fatalf("unexpected child context: %#v", actual) + } } diff --git a/cmd/commands/assess_distribution.go b/cmd/commands/assess_distribution.go index 94615ef..82f2b41 100644 --- a/cmd/commands/assess_distribution.go +++ b/cmd/commands/assess_distribution.go @@ -8,7 +8,7 @@ import ( "sort" "strings" - "github.com/featurevisor/featurevisor-go" + "github.com/featurevisor/featurevisor-go/v2" ) // UUID_LENGTHS matches the TypeScript implementation diff --git a/cmd/commands/benchmark.go b/cmd/commands/benchmark.go index b54d82e..59541d2 100644 --- a/cmd/commands/benchmark.go +++ b/cmd/commands/benchmark.go @@ -6,7 +6,7 @@ import ( "strings" "time" - featurevisor "github.com/featurevisor/featurevisor-go" + featurevisor "github.com/featurevisor/featurevisor-go/v2" ) // BenchmarkOutput represents the result of a benchmark operation diff --git a/cmd/commands/test.go b/cmd/commands/test.go index 03657ed..15a115b 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/featurevisor/featurevisor-go" + "github.com/featurevisor/featurevisor-go/v2" ) // TestFeature tests a feature with the given assertion @@ -89,6 +89,7 @@ func RunTestFeature(assertion map[string]interface{}, featureKey string, instanc if defaultValues, ok := assertion["defaultVariableValues"].(map[string]interface{}); ok { if defaultVal, ok := defaultValues[variableKey]; ok { overrideOptions.DefaultVariableValue = defaultVal + overrideOptions.DefaultVariableValueSet = true } } @@ -232,6 +233,52 @@ func RunTestFeature(assertion map[string]interface{}, featureKey string, instanc } } + // Test expectedEvaluations + if expectedEvaluations, ok := assertion["expectedEvaluations"].(map[string]interface{}); ok { + if flagEvals, ok := expectedEvaluations["flag"].(map[string]interface{}); ok { + evaluation := instance.EvaluateFlag(featureKey, context, overrideOptions) + for key, expectedValue := range flagEvals { + actualValue := getEvaluationValue(evaluation, key) + if !compareValues(actualValue, expectedValue) { + hasError = true + errors += fmt.Sprintf(" ✘ expectedEvaluations.flag.%s: expected %v but received %v\n", key, expectedValue, actualValue) + } + } + } + + if variationEvals, ok := expectedEvaluations["variation"].(map[string]interface{}); ok { + evaluation := instance.EvaluateVariation(featureKey, context, overrideOptions) + for key, expectedValue := range variationEvals { + actualValue := getEvaluationValue(evaluation, key) + if !compareValues(actualValue, expectedValue) { + hasError = true + errors += fmt.Sprintf(" ✘ expectedEvaluations.variation.%s: expected %v but received %v\n", key, expectedValue, actualValue) + } + } + } + + if variableEvals, ok := expectedEvaluations["variables"].(map[string]interface{}); ok { + for variableKey, expectedEval := range variableEvals { + expectedEvalMap, ok := expectedEval.(map[string]interface{}) + if !ok { + continue + } + + variableOptions := overrideOptions + variableOptions.DefaultVariableValue, variableOptions.DefaultVariableValueSet = + getDefaultVariableValue(assertion, variableKey) + evaluation := instance.EvaluateVariable(featureKey, variableKey, context, variableOptions) + for key, expectedValue := range expectedEvalMap { + actualValue := getEvaluationValue(evaluation, key) + if !compareValues(actualValue, expectedValue) { + hasError = true + errors += fmt.Sprintf(" ✘ expectedEvaluations.variables.%s.%s: expected %v but received %v\n", variableKey, key, expectedValue, actualValue) + } + } + } + } + } + duration := time.Since(startTime).Seconds() return AssertionResult{ @@ -291,6 +338,7 @@ func RunTestFeatureChild(assertion map[string]interface{}, featureKey string, in if defaultValues, ok := assertion["defaultVariableValues"].(map[string]interface{}); ok { if defaultVal, ok := defaultValues[variableKey]; ok { overrideOptions.DefaultVariableValue = defaultVal + overrideOptions.DefaultVariableValueSet = true } } @@ -337,6 +385,51 @@ func RunTestFeatureChild(assertion map[string]interface{}, featureKey string, in } } + if expectedEvaluations, ok := assertion["expectedEvaluations"].(map[string]interface{}); ok { + if flagEvaluations, ok := expectedEvaluations["flag"].(map[string]interface{}); ok { + evaluation := instance.EvaluateFlag(featureKey, context, overrideOptions) + for key, expectedValue := range flagEvaluations { + actualValue := getEvaluationValue(evaluation, key) + if !compareValues(actualValue, expectedValue) { + hasError = true + errors += fmt.Sprintf(" ✘ expectedEvaluations.flag.%s: expected %v but received %v\n", key, expectedValue, actualValue) + } + } + } + + if variationEvaluations, ok := expectedEvaluations["variation"].(map[string]interface{}); ok { + evaluation := instance.EvaluateVariation(featureKey, context, overrideOptions) + for key, expectedValue := range variationEvaluations { + actualValue := getEvaluationValue(evaluation, key) + if !compareValues(actualValue, expectedValue) { + hasError = true + errors += fmt.Sprintf(" ✘ expectedEvaluations.variation.%s: expected %v but received %v\n", key, expectedValue, actualValue) + } + } + } + + if variableEvaluations, ok := expectedEvaluations["variables"].(map[string]interface{}); ok { + for variableKey, expectedEvaluationValue := range variableEvaluations { + expectedEvaluation, ok := expectedEvaluationValue.(map[string]interface{}) + if !ok { + continue + } + + variableOptions := overrideOptions + variableOptions.DefaultVariableValue, variableOptions.DefaultVariableValueSet = + getDefaultVariableValue(assertion, variableKey) + evaluation := instance.EvaluateVariable(featureKey, variableKey, context, variableOptions) + for key, expectedValue := range expectedEvaluation { + actualValue := getEvaluationValue(evaluation, key) + if !compareValues(actualValue, expectedValue) { + hasError = true + errors += fmt.Sprintf(" ✘ expectedEvaluations.variables.%s.%s: expected %v but received %v\n", variableKey, key, expectedValue, actualValue) + } + } + } + } + } + duration := time.Since(startTime).Seconds() return AssertionResult{ @@ -455,15 +548,13 @@ func getEvaluationValue(evaluation featurevisor.Evaluation, key string) interfac } } -func getDefaultVariableValue(assertion map[string]interface{}, variableKey string) featurevisor.VariableValue { +func getDefaultVariableValue(assertion map[string]interface{}, variableKey string) (featurevisor.VariableValue, bool) { if defaultValues, ok := assertion["defaultVariableValues"].(map[string]interface{}); ok { if defaultVal, ok := defaultValues[variableKey]; ok { - if val, ok := defaultVal.(featurevisor.VariableValue); ok { - return val - } + return defaultVal, true } } - return nil + return nil, false } // compareSlices compares two slices for equality diff --git a/cmd/commands/test_evaluation_value_test.go b/cmd/commands/test_evaluation_value_test.go index 5839772..530f895 100644 --- a/cmd/commands/test_evaluation_value_test.go +++ b/cmd/commands/test_evaluation_value_test.go @@ -3,7 +3,7 @@ package commands import ( "testing" - "github.com/featurevisor/featurevisor-go" + "github.com/featurevisor/featurevisor-go/v2" ) func TestGetEvaluationValueVariableOverrideIndex(t *testing.T) { diff --git a/cmd/commands/test_types.go b/cmd/commands/test_types.go index 6461870..e831e81 100644 --- a/cmd/commands/test_types.go +++ b/cmd/commands/test_types.go @@ -1,6 +1,6 @@ package commands -import "github.com/featurevisor/featurevisor-go" +import "github.com/featurevisor/featurevisor-go/v2" // AssertionMatrix represents a matrix of assertions type AssertionMatrix map[string][]featurevisor.AttributeValue diff --git a/cmd/go.mod b/cmd/go.mod deleted file mode 100644 index 1b1840b..0000000 --- a/cmd/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/featurevisor/featurevisor-go/cmd - -go 1.21 diff --git a/cmd/main.go b/cmd/main.go index d12649e..fe699a3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/featurevisor/featurevisor-go/cmd/commands" + "github.com/featurevisor/featurevisor-go/v2/cmd/commands" ) func main() { diff --git a/compare_versions.go b/compare_versions.go index 59e68da..32e4dc7 100644 --- a/compare_versions.go +++ b/compare_versions.go @@ -7,18 +7,16 @@ import ( "strings" ) -// SemverRegex is the regular expression for parsing semantic versions -// Ported from the TypeScript implementation -var SemverRegex = regexp.MustCompile(`(?i)^[v^~<>=]*?(\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+))?(?:-([\da-z\-]+(?:\.[\da-z\-]+)*))?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$`) +var semverRegex = regexp.MustCompile(`(?i)^[v^~<>=]*?(\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+))?(?:-([\da-z\-]+(?:\.[\da-z\-]+)*))?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$`) -// ValidateAndParse validates and parses a semantic version string +// validateAndParse validates and parses a semantic version string // Returns the parsed segments or an error if invalid -func ValidateAndParse(version string) ([]string, error) { +func validateAndParse(version string) ([]string, error) { if version == "" { return nil, fmt.Errorf("invalid argument expected string") } - matches := SemverRegex.FindStringSubmatch(version) + matches := semverRegex.FindStringSubmatch(version) if matches == nil { return nil, fmt.Errorf("invalid argument not valid semver ('%s' received)", version) } @@ -27,34 +25,34 @@ func ValidateAndParse(version string) ([]string, error) { return matches[1:], nil } -// IsWildcard checks if a string is a wildcard character -func IsWildcard(s string) bool { +// isWildcard checks if a string is a wildcard character +func isWildcard(s string) bool { return s == "*" || s == "x" || s == "X" } -// ForceType ensures both values are of the same type for comparison -func ForceType(a, b interface{}) (interface{}, interface{}) { +// forceType ensures both values are of the same type for comparison +func forceType(a, b interface{}) (interface{}, interface{}) { if fmt.Sprintf("%T", a) != fmt.Sprintf("%T", b) { return fmt.Sprintf("%v", a), fmt.Sprintf("%v", b) } return a, b } -// TryParse attempts to parse a string as an integer, returns the original string if it fails -func TryParse(v string) interface{} { +// tryParse attempts to parse a string as an integer, returns the original string if it fails +func tryParse(v string) interface{} { if n, err := strconv.Atoi(v); err == nil { return n } return v } -// CompareStrings compares two strings, handling wildcards and mixed types -func CompareStrings(a, b string) int { - if IsWildcard(a) || IsWildcard(b) { +// compareStrings compares two strings, handling wildcards and mixed types +func compareStrings(a, b string) int { + if isWildcard(a) || isWildcard(b) { return 0 } - ap, bp := ForceType(TryParse(a), TryParse(b)) + ap, bp := forceType(tryParse(a), tryParse(b)) switch apVal := ap.(type) { case int: @@ -78,8 +76,8 @@ func CompareStrings(a, b string) int { } } -// CompareSegments compares two arrays of version segments -func CompareSegments(a, b []string) int { +// compareSegments compares two arrays of version segments +func compareSegments(a, b []string) int { maxLen := len(a) if len(b) > maxLen { maxLen = len(b) @@ -96,7 +94,7 @@ func CompareSegments(a, b []string) int { bVal = b[i] } - result := CompareStrings(aVal, bVal) + result := compareStrings(aVal, bVal) if result != 0 { return result } @@ -105,20 +103,20 @@ func CompareSegments(a, b []string) int { return 0 } -// CompareVersions compares two semantic version strings +// compareVersions compares two semantic version strings // Returns: // // -1 if v1 < v2 // 0 if v1 == v2 // 1 if v1 > v2 -func CompareVersions(v1, v2 string) (int, error) { +func compareVersions(v1, v2 string) (int, error) { // Validate input and split into segments - n1, err := ValidateAndParse(v1) + n1, err := validateAndParse(v1) if err != nil { return 0, fmt.Errorf("invalid first version: %w", err) } - n2, err := ValidateAndParse(v2) + n2, err := validateAndParse(v2) if err != nil { return 0, fmt.Errorf("invalid second version: %w", err) } @@ -135,7 +133,7 @@ func CompareVersions(v1, v2 string) (int, error) { } // Compare main version segments - result := CompareSegments(n1, n2) + result := compareSegments(n1, n2) if result != 0 { return result, nil } @@ -144,7 +142,7 @@ func CompareVersions(v1, v2 string) (int, error) { if p1 != "" && p2 != "" { p1Parts := strings.Split(p1, ".") p2Parts := strings.Split(p2, ".") - return CompareSegments(p1Parts, p2Parts), nil + return compareSegments(p1Parts, p2Parts), nil } else if p1 != "" || p2 != "" { if p1 != "" { return -1, nil diff --git a/compare_versions_test.go b/compare_versions_test.go index 5201151..6bfa3bf 100644 --- a/compare_versions_test.go +++ b/compare_versions_test.go @@ -54,17 +54,17 @@ func TestValidateAndParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := ValidateAndParse(tt.version) + result, err := validateAndParse(tt.version) if tt.expectError { if err == nil { - t.Errorf("ValidateAndParse(%s) expected error but got none", tt.version) + t.Errorf("validateAndParse(%s) expected error but got none", tt.version) } } else { if err != nil { - t.Errorf("ValidateAndParse(%s) unexpected error: %v", tt.version, err) + t.Errorf("validateAndParse(%s) unexpected error: %v", tt.version, err) } if len(result) == 0 { - t.Errorf("ValidateAndParse(%s) returned empty result", tt.version) + t.Errorf("validateAndParse(%s) returned empty result", tt.version) } } }) @@ -86,9 +86,9 @@ func TestIsWildcard(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - result := IsWildcard(tt.input) + result := isWildcard(tt.input) if result != tt.expected { - t.Errorf("IsWildcard(%s) = %v, expected %v", tt.input, result, tt.expected) + t.Errorf("isWildcard(%s) = %v, expected %v", tt.input, result, tt.expected) } }) } @@ -108,9 +108,9 @@ func TestTryParse(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - result := TryParse(tt.input) + result := tryParse(tt.input) if result != tt.expected { - t.Errorf("TryParse(%s) = %v, expected %v", tt.input, result, tt.expected) + t.Errorf("tryParse(%s) = %v, expected %v", tt.input, result, tt.expected) } }) } @@ -137,9 +137,9 @@ func TestCompareStrings(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := CompareStrings(tt.a, tt.b) + result := compareStrings(tt.a, tt.b) if result != tt.expected { - t.Errorf("CompareStrings(%s, %s) = %d, expected %d", tt.a, tt.b, result, tt.expected) + t.Errorf("compareStrings(%s, %s) = %d, expected %d", tt.a, tt.b, result, tt.expected) } }) } @@ -163,9 +163,9 @@ func TestCompareSegments(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := CompareSegments(tt.a, tt.b) + result := compareSegments(tt.a, tt.b) if result != tt.expected { - t.Errorf("CompareSegments(%v, %v) = %d, expected %d", tt.a, tt.b, result, tt.expected) + t.Errorf("compareSegments(%v, %v) = %d, expected %d", tt.a, tt.b, result, tt.expected) } }) } @@ -265,17 +265,17 @@ func TestCompareVersions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := CompareVersions(tt.v1, tt.v2) + result, err := compareVersions(tt.v1, tt.v2) if tt.expectError { if err == nil { - t.Errorf("CompareVersions(%s, %s) expected error but got none", tt.v1, tt.v2) + t.Errorf("compareVersions(%s, %s) expected error but got none", tt.v1, tt.v2) } } else { if err != nil { - t.Errorf("CompareVersions(%s, %s) unexpected error: %v", tt.v1, tt.v2, err) + t.Errorf("compareVersions(%s, %s) unexpected error: %v", tt.v1, tt.v2, err) } if result != tt.expected { - t.Errorf("CompareVersions(%s, %s) = %d, expected %d", tt.v1, tt.v2, result, tt.expected) + t.Errorf("compareVersions(%s, %s) = %d, expected %d", tt.v1, tt.v2, result, tt.expected) } } }) @@ -288,6 +288,6 @@ func BenchmarkCompareVersions(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - CompareVersions(v1, v2) + compareVersions(v1, v2) } } diff --git a/conditions.go b/conditions.go index 1944075..4e259bc 100644 --- a/conditions.go +++ b/conditions.go @@ -1,16 +1,102 @@ package featurevisor import ( + "reflect" "regexp" "strings" "time" ) -// GetRegex is a function type for getting regex patterns -type GetRegex func(regexString string, regexFlags string) *regexp.Regexp +func numericValue(value interface{}) (float64, bool) { + switch v := value.(type) { + case int: + return float64(v), true + case int8: + return float64(v), true + case int16: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case uint: + return float64(v), true + case uint8: + return float64(v), true + case uint16: + return float64(v), true + case uint32: + return float64(v), true + case uint64: + return float64(v), true + case float32: + return float64(v), true + case float64: + return v, true + default: + return 0, false + } +} + +func primitiveSliceValues(value interface{}) ([]interface{}, bool) { + if value == nil { + return nil, false + } + + reflected := reflect.ValueOf(value) + if reflected.Kind() != reflect.Array && reflected.Kind() != reflect.Slice { + return nil, false + } + + values := make([]interface{}, reflected.Len()) + for index := 0; index < reflected.Len(); index++ { + item := reflected.Index(index).Interface() + if !isConditionPrimitive(item) { + return nil, false + } + values[index] = item + } + + return values, true +} + +func strictPrimitiveEqual(left interface{}, right interface{}) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + if leftNumber, ok := numericValue(left); ok { + rightNumber, rightIsNumber := numericValue(right) + return rightIsNumber && leftNumber == rightNumber + } + switch leftValue := left.(type) { + case string: + rightValue, ok := right.(string) + return ok && leftValue == rightValue + case bool: + rightValue, ok := right.(bool) + return ok && leftValue == rightValue + } + return false +} + +func isConditionPrimitive(value interface{}) bool { + if value == nil { + return true + } + if _, ok := value.(string); ok { + return true + } + if _, ok := value.(bool); ok { + return true + } + _, ok := numericValue(value) + return ok +} -// PathExists checks if a path exists in a context object -func PathExists(obj map[string]interface{}, path string) bool { +type getRegex func(regexString string, regexFlags string) *regexp.Regexp + +// pathExists checks if a path exists in a context object +func pathExists(obj map[string]interface{}, path string) bool { if !strings.Contains(path, ".") { _, exists := obj[path] return exists @@ -37,8 +123,8 @@ func PathExists(obj map[string]interface{}, path string) bool { return true } -// GetValueFromContext extracts a value from a context object using a dot-separated path -func GetValueFromContext(obj map[string]interface{}, path string) interface{} { +// getValueFromContext extracts a value from a context object using a dot-separated path +func getValueFromContext(obj map[string]interface{}, path string) interface{} { if !strings.Contains(path, ".") { return obj[path] } @@ -61,20 +147,22 @@ func GetValueFromContext(obj map[string]interface{}, path string) interface{} { return current } -// ConditionIsMatched checks if a condition is matched given a context -func ConditionIsMatched( +// conditionIsMatched checks if a condition is matched given a context +func conditionIsMatched( condition PlainCondition, context Context, - getRegex GetRegex, + getRegex getRegex, ) bool { - contextValueFromPath := GetValueFromContext(context, string(condition.Attribute)) + attribute := string(condition.Attribute) + contextValueFromPath := getValueFromContext(context, attribute) + attributeExists := pathExists(context, attribute) // Handle nil values if condition.Value == nil { if condition.Operator == OperatorExists { - return contextValueFromPath != nil + return attributeExists } else if condition.Operator == OperatorNotExists { - return contextValueFromPath == nil + return !attributeExists } return false } @@ -83,9 +171,9 @@ func ConditionIsMatched( // equals / notEquals if condition.Operator == OperatorEquals { - return contextValueFromPath == value + return attributeExists && strictPrimitiveEqual(contextValueFromPath, value) } else if condition.Operator == OperatorNotEquals { - return contextValueFromPath != value + return !attributeExists || !strictPrimitiveEqual(contextValueFromPath, value) } // before / after (date comparisons) @@ -101,11 +189,7 @@ func ConditionIsMatched( case string: dateInContext, err = time.Parse(time.RFC3339, v) if err != nil { - // Try other common formats - dateInContext, err = time.Parse("2006-01-02", v) - if err != nil { - return false - } + return false } default: return false @@ -118,11 +202,7 @@ func ConditionIsMatched( case string: dateInCondition, err = time.Parse(time.RFC3339, v) if err != nil { - // Try other common formats - dateInCondition, err = time.Parse("2006-01-02", v) - if err != nil { - return false - } + return false } default: return false @@ -136,70 +216,24 @@ func ConditionIsMatched( } // in / notIn (where condition value is an array) - if valueArray, ok := value.([]interface{}); ok { - if contextValueFromPath == nil { - if condition.Operator == OperatorIn { - return false - } else if condition.Operator == OperatorNotIn { - // Check if the path exists in the context first (like PHP implementation) - if !PathExists(context, string(condition.Attribute)) { - return false - } - // null is not in the array, so return true - return true - } + if valueArray, ok := primitiveSliceValues(value); ok { + _, isString := contextValueFromPath.(string) + _, isNumber := numericValue(contextValueFromPath) + if !attributeExists || (!isString && !isNumber && contextValueFromPath != nil) { + return false } - - // Handle case where context value is also an array - if contextArray, ok := contextValueFromPath.([]interface{}); ok { - // For arrays in context, check if any element from context array is in condition array - if condition.Operator == OperatorIn { - for _, contextItem := range contextArray { - for _, conditionItem := range valueArray { - if contextItem == conditionItem { - return true - } - } - } - return false - } else if condition.Operator == OperatorNotIn { - // For notIn with array context values, return false (like PHP implementation) - // PHP only handles notIn for string, numeric, or null context values - return false - } - } else { - // Context value is a single value - valueInContext := contextValueFromPath - - // Only handle in/notIn for string, numeric, or null context values (like PHP implementation) - switch valueInContext.(type) { - case string, int, float64, bool: - if condition.Operator == OperatorIn { - // Check if context value is in the condition's array - for _, item := range valueArray { - if item == valueInContext { - return true - } - } - return false - } else if condition.Operator == OperatorNotIn { - // Check if the path exists in the context first (like PHP implementation) - if !PathExists(context, string(condition.Attribute)) { - return false - } - // Check if context value is NOT in the condition's array - for _, item := range valueArray { - if item == valueInContext { - return false - } - } - return true - } - default: - // For other types (like objects, arrays), don't match in/notIn conditions - return false + matched := false + for _, item := range valueArray { + if strictPrimitiveEqual(item, contextValueFromPath) { + matched = true + break } } + if condition.Operator == OperatorIn { + return matched + } else if condition.Operator == OperatorNotIn { + return !matched + } } // String operations @@ -215,23 +249,41 @@ func ConditionIsMatched( case OperatorEndsWith: return strings.HasSuffix(contextValueStr, valueStr) case OperatorSemverEquals: - result, err := CompareVersions(contextValueStr, valueStr) - return err == nil && result == 0 + result, err := compareVersions(contextValueStr, valueStr) + if err != nil { + panic(err) + } + return result == 0 case OperatorSemverNotEquals: - result, err := CompareVersions(contextValueStr, valueStr) - return err == nil && result != 0 + result, err := compareVersions(contextValueStr, valueStr) + if err != nil { + panic(err) + } + return result != 0 case OperatorSemverGreaterThan: - result, err := CompareVersions(contextValueStr, valueStr) - return err == nil && result == 1 + result, err := compareVersions(contextValueStr, valueStr) + if err != nil { + panic(err) + } + return result == 1 case OperatorSemverGreaterThanOrEquals: - result, err := CompareVersions(contextValueStr, valueStr) - return err == nil && result >= 0 + result, err := compareVersions(contextValueStr, valueStr) + if err != nil { + panic(err) + } + return result >= 0 case OperatorSemverLessThan: - result, err := CompareVersions(contextValueStr, valueStr) - return err == nil && result == -1 + result, err := compareVersions(contextValueStr, valueStr) + if err != nil { + panic(err) + } + return result == -1 case OperatorSemverLessThanOrEquals: - result, err := CompareVersions(contextValueStr, valueStr) - return err == nil && result <= 0 + result, err := compareVersions(contextValueStr, valueStr) + if err != nil { + panic(err) + } + return result <= 0 case OperatorMatches: regexFlags := "" if condition.RegexFlags != nil { @@ -250,90 +302,43 @@ func ConditionIsMatched( } } - // Numeric operations - if contextValueNum, ok := contextValueFromPath.(float64); ok { - if valueNum, ok := value.(float64); ok { - switch condition.Operator { - case OperatorGreaterThan: - return contextValueNum > valueNum - case OperatorGreaterThanOrEquals: - return contextValueNum >= valueNum - case OperatorLessThan: - return contextValueNum < valueNum - case OperatorLessThanOrEquals: - return contextValueNum <= valueNum - } - } - } - - // Handle integer types - if contextValueInt, ok := contextValueFromPath.(int); ok { - if valueInt, ok := value.(int); ok { - switch condition.Operator { - case OperatorGreaterThan: - return contextValueInt > valueInt - case OperatorGreaterThanOrEquals: - return contextValueInt >= valueInt - case OperatorLessThan: - return contextValueInt < valueInt - case OperatorLessThanOrEquals: - return contextValueInt <= valueInt - } - } - } - - // Handle mixed numeric types - if contextValueFloat, ok := contextValueFromPath.(float64); ok { - if valueInt, ok := value.(int); ok { + // Numeric operations use the same cross-number comparison for every native Go number type. + if contextValueNumber, contextIsNumber := numericValue(contextValueFromPath); contextIsNumber { + if conditionValueNumber, conditionIsNumber := numericValue(value); conditionIsNumber { switch condition.Operator { case OperatorGreaterThan: - return contextValueFloat > float64(valueInt) + return contextValueNumber > conditionValueNumber case OperatorGreaterThanOrEquals: - return contextValueFloat >= float64(valueInt) + return contextValueNumber >= conditionValueNumber case OperatorLessThan: - return contextValueFloat < float64(valueInt) + return contextValueNumber < conditionValueNumber case OperatorLessThanOrEquals: - return contextValueFloat <= float64(valueInt) - } - } - } - - if contextValueInt, ok := contextValueFromPath.(int); ok { - if valueFloat, ok := value.(float64); ok { - switch condition.Operator { - case OperatorGreaterThan: - return float64(contextValueInt) > valueFloat - case OperatorGreaterThanOrEquals: - return float64(contextValueInt) >= valueFloat - case OperatorLessThan: - return float64(contextValueInt) < valueFloat - case OperatorLessThanOrEquals: - return float64(contextValueInt) <= valueFloat + return contextValueNumber <= conditionValueNumber } } } // exists / notExists if condition.Operator == OperatorExists { - return contextValueFromPath != nil + return attributeExists } else if condition.Operator == OperatorNotExists { - return contextValueFromPath == nil + return !attributeExists } // includes / notIncludes (where context value is an array) - if contextValueArray, ok := contextValueFromPath.([]interface{}); ok { - if valueStr, ok := value.(string); ok { + if contextValueArray, ok := primitiveSliceValues(contextValueFromPath); ok { + if isConditionPrimitive(value) { switch condition.Operator { case OperatorIncludes: for _, item := range contextValueArray { - if itemStr, ok := item.(string); ok && itemStr == valueStr { + if strictPrimitiveEqual(item, value) { return true } } return false case OperatorNotIncludes: for _, item := range contextValueArray { - if itemStr, ok := item.(string); ok && itemStr == valueStr { + if strictPrimitiveEqual(item, value) { return false } } diff --git a/conditions_test.go b/conditions_test.go index 0b33651..6a94243 100644 --- a/conditions_test.go +++ b/conditions_test.go @@ -65,7 +65,7 @@ func TestGetValueFromContext(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := GetValueFromContext(context, tt.path) + result := getValueFromContext(context, tt.path) if tt.expected == nil { // Skip comparison for nil expected values return @@ -206,7 +206,7 @@ func TestConditionIsMatched(t *testing.T) { Value: conditionValue([]interface{}{"premium", "active"}), }, context: context, - expected: true, + expected: false, // `in` only accepts a primitive context value }, { name: "includes in array", @@ -253,7 +253,7 @@ func TestConditionIsMatched(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ConditionIsMatched(tt.condition, tt.context, getRegex) + result := conditionIsMatched(tt.condition, tt.context, getRegex) if result != tt.expected { t.Errorf("conditionIsMatched() = %v, expected %v", result, tt.expected) } @@ -305,7 +305,7 @@ func TestConditionIsMatchedDate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ConditionIsMatched(tt.condition, tt.context, getRegex) + result := conditionIsMatched(tt.condition, tt.context, getRegex) if result != tt.expected { t.Errorf("conditionIsMatched() = %v, expected %v", result, tt.expected) } @@ -313,9 +313,118 @@ func TestConditionIsMatchedDate(t *testing.T) { } } +func TestConditionJavaScriptPrimitiveParity(t *testing.T) { + getRegex := func(pattern string, flags string) *regexp.Regexp { + return regexp.MustCompile(pattern) + } + value := func(value interface{}) *ConditionValue { + conditionValue := ConditionValue(value) + return &conditionValue + } + + tests := []struct { + name string + condition PlainCondition + context Context + expected bool + }{ + {"number is not string", PlainCondition{Attribute: "value", Operator: OperatorEquals, Value: value(1)}, Context{"value": "1"}, false}, + {"number is not boolean", PlainCondition{Attribute: "value", Operator: OperatorEquals, Value: value(1)}, Context{"value": true}, false}, + {"explicit null exists", PlainCondition{Attribute: "value", Operator: OperatorExists}, Context{"value": nil}, true}, + {"explicit null is not missing", PlainCondition{Attribute: "value", Operator: OperatorNotExists}, Context{"value": nil}, false}, + {"in is strict", PlainCondition{Attribute: "value", Operator: OperatorIn, Value: value([]interface{}{1})}, Context{"value": "1"}, false}, + {"includes number", PlainCondition{Attribute: "values", Operator: OperatorIncludes, Value: value(1)}, Context{"values": []interface{}{1, true, nil}}, true}, + {"includes boolean", PlainCondition{Attribute: "values", Operator: OperatorIncludes, Value: value(true)}, Context{"values": []interface{}{1, true, nil}}, true}, + {"includes null", PlainCondition{Attribute: "values", Operator: OperatorIncludes, Value: value(nil)}, Context{"values": []interface{}{1, true, nil}}, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if actual := conditionIsMatched(test.condition, test.context, getRegex); actual != test.expected { + t.Fatalf("got %v, want %v", actual, test.expected) + } + }) + } +} + +func TestConditionSupportsNativeGoNumberTypes(t *testing.T) { + getRegex := func(pattern string, flags string) *regexp.Regexp { + return regexp.MustCompile(pattern) + } + tests := []struct { + name string + context interface{} + expected interface{} + operator Operator + }{ + {"int8", int8(2), int16(1), OperatorGreaterThan}, + {"int64", int64(2), int32(2), OperatorGreaterThanOrEquals}, + {"uint32", uint32(2), uint8(3), OperatorLessThan}, + {"float32", float32(2.5), float64(2.5), OperatorLessThanOrEquals}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + condition := PlainCondition{ + Attribute: "value", + Operator: test.operator, + Value: conditionValue(test.expected), + } + if !conditionIsMatched(condition, Context{"value": test.context}, getRegex) { + t.Fatalf("expected %#v %s %#v to match", test.context, test.operator, test.expected) + } + }) + } +} + +func TestConditionSupportsNativeGoPrimitiveSlices(t *testing.T) { + getRegex := func(pattern string, flags string) *regexp.Regexp { + return regexp.MustCompile(pattern) + } + tests := []struct { + name string + context interface{} + value interface{} + }{ + {"strings", []string{"admin", "editor"}, "editor"}, + {"integers", []int64{1, 2}, int32(2)}, + {"booleans", []bool{false, true}, true}, + {"array", [2]uint16{3, 4}, uint8(4)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + includes := PlainCondition{ + Attribute: "values", + Operator: OperatorIncludes, + Value: conditionValue(test.value), + } + if !conditionIsMatched(includes, Context{"values": test.context}, getRegex) { + t.Fatalf("expected %#v to include %#v", test.context, test.value) + } + + notIncludes := includes + notIncludes.Operator = OperatorNotIncludes + notIncludes.Value = conditionValue("missing") + if !conditionIsMatched(notIncludes, Context{"values": test.context}, getRegex) { + t.Fatalf("expected %#v not to include missing", test.context) + } + }) + } + + in := PlainCondition{ + Attribute: "value", + Operator: OperatorIn, + Value: conditionValue([]string{"nl", "de"}), + } + if !conditionIsMatched(in, Context{"value": "nl"}, getRegex) { + t.Fatal("expected a native []string condition value to support in") + } +} + // TestConditionIsMatchedComprehensive tests all operators comprehensively func TestConditionIsMatchedComprehensive(t *testing.T) { - logger := newLogger(loggerOptions{}) + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "1", @@ -328,9 +437,9 @@ func TestConditionIsMatchedComprehensive(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: datafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) // Test wildcard conditions @@ -624,6 +733,22 @@ func TestConditionIsMatchedComprehensive(t *testing.T) { if !reader.AllConditionsAreMatched(condition, context) { t.Error("semverLessThanOrEquals operator should match") } + condition = PlainCondition{ + Attribute: "version", + Operator: OperatorSemverLessThan, + Value: conditionValue("1.2.3"), + } + if !reader.AllConditionsAreMatched(condition, Context{"version": "1.2.3-beta.1"}) { + t.Error("prerelease should be less than release") + } + condition = PlainCondition{ + Attribute: "version", + Operator: OperatorSemverEquals, + Value: conditionValue("1.2.3+build.9"), + } + if !reader.AllConditionsAreMatched(condition, Context{"version": "1.2.3+build.5"}) { + t.Error("build metadata should not affect semver equality") + } // before condition = PlainCondition{ @@ -635,6 +760,12 @@ func TestConditionIsMatchedComprehensive(t *testing.T) { if !reader.AllConditionsAreMatched(condition, context) { t.Error("before operator should match") } + if reader.AllConditionsAreMatched(condition, Context{"date": "2023-05-12T00:00:00"}) { + t.Error("timezone-free date should not match") + } + if reader.AllConditionsAreMatched(condition, Context{"date": "2023-05-13T17:23:59+01:00"}) { + t.Error("equivalent offset instant should not be before") + } // after condition = PlainCondition{ @@ -769,7 +900,7 @@ func TestConditionIsMatchedEdgeCases(t *testing.T) { Value: conditionValue([]interface{}{"chrome", "firefox"}), }, context: Context{"browser_types": []interface{}{"chrome", "safari"}}, - expected: true, // chrome is in both arrays + expected: false, // arrays are not valid context values for `in` }, { name: "array context value with notIn operator", @@ -846,7 +977,7 @@ func TestConditionIsMatchedEdgeCases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ConditionIsMatched(tt.condition, tt.context, getRegex) + result := conditionIsMatched(tt.condition, tt.context, getRegex) if result != tt.expected { t.Errorf("conditionIsMatched() = %v, expected %v", result, tt.expected) } @@ -855,7 +986,7 @@ func TestConditionIsMatchedEdgeCases(t *testing.T) { } func TestConditionIsMatchedComplexNested(t *testing.T) { - logger := newLogger(loggerOptions{}) + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "1", @@ -868,9 +999,9 @@ func TestConditionIsMatchedComplexNested(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: datafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) // Test complex nested conditions similar to TypeScript tests @@ -965,6 +1096,6 @@ func BenchmarkConditionIsMatched(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - ConditionIsMatched(condition, context, getRegex) + conditionIsMatched(condition, context, getRegex) } } diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index ceae692..49396ce 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "description": "Featurevisor v3 cross SDK compatibility contracts", "bucketing": { "minimum": 0, @@ -26,7 +26,47 @@ "pattern": "chrome", "flags": "g", "values": ["chrome", "chrome", "firefox", "chrome"], - "matches": [true, true, false, true] + "matches": [true, true, false, true], + "portableCases": [ + { + "pattern": "^chrome$", + "flags": "", + "value": "chrome", + "expected": true + }, + { + "pattern": "^(chrome|firefox)$", + "flags": "i", + "value": "Firefox", + "expected": true + }, + { + "pattern": "^second$", + "flags": "m", + "value": "first\nsecond", + "expected": true + }, + { + "pattern": "first.*second", + "flags": "s", + "value": "first\nsecond", + "expected": true + }, + { + "pattern": "\\(literal\\)", + "flags": "g", + "value": "(literal)", + "expected": true + } + ], + "rejectedSyntax": [ + "foo(?=bar)", + "(?<=foo)bar", + "(?:foo|bar)", + "(?foo)", + "(foo)\\1", + "foo++" + ] }, "typedVariables": [ { "type": "integer", "value": 1, "valid": true }, @@ -44,6 +84,135 @@ "diagnostics": { "requiredFields": ["level", "code", "message", "details"], "detailsType": "object", - "emptyDetailsJson": "{}" + "emptyDetailsJson": "{}", + "evaluationDetailFields": ["featureKey", "variableKey", "reason", "evaluation"], + "moduleEnvelopeFields": ["module", "moduleName", "originalError"], + "errorEventLevels": ["error"] + }, + "numericBucketKeys": [ + { "value": 1.2345678901234567, "expected": "1.2345678901234567" }, + { "value": 0.30000000000000004, "expected": "0.30000000000000004" }, + { "value": 0.000001, "expected": "0.000001" }, + { "value": 1e-7, "expected": "1e-7" }, + { "value": 100000000000000000000, "expected": "100000000000000000000" }, + { "value": 1e21, "expected": "1e+21" } + ], + "portableConditions": { + "regexFlags": ["g", "i", "m", "s"], + "rejectedRegexFlags": ["d", "u", "v", "y"], + "dateFormat": "ISO 8601 with an explicit timezone", + "dates": [ + "2024-01-01T00:00:00Z", + "2024-01-01T01:00:00+01:00", + "2024-01-01T00:00:00.250Z", + "2024-01-01T01:00:00.250+01:00" + ], + "semanticVersions": [ + "1.2.3", + "1.2.3-beta.1", + "1.2.3+build.5" + ], + "invalidSemanticVersion": "invalid", + "invalidSemanticVersionDiagnosticCode": "condition_match_error" + }, + "conditionCases": [ + { + "name": "strict primitive equality", + "condition": { + "attribute": "value", + "operator": "equals", + "value": 1 + }, + "context": { "value": "1" }, + "expected": false + }, + { + "name": "not negates implicit and", + "condition": { + "not": [ + { "attribute": "country", "operator": "equals", "value": "us" }, + { "attribute": "device", "operator": "equals", "value": "mobile" } + ] + }, + "context": { "country": "us", "device": "desktop" }, + "expected": true + }, + { + "name": "not with nested or means none match", + "condition": { + "not": [ + { + "or": [ + { "attribute": "country", "operator": "equals", "value": "us" }, + { "attribute": "country", "operator": "equals", "value": "nl" } + ] + } + ] + }, + "context": { "country": "de" }, + "expected": true + }, + { + "name": "empty not fails defensively", + "condition": { "not": [] }, + "context": {}, + "expected": false + }, + { + "name": "fractional ISO date with offset", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.500Z" + }, + "context": { "date": "2024-01-01T01:00:00.250+01:00" }, + "expected": true + } + ], + "childInstances": { + "contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win", + "closeRemovesLocalAndDelegatedSubscriptions": true, + "detailedEvaluationMethods": ["flag", "variation", "variable"], + "contextCase": { + "parentAtSpawn": { "country": "nl", "plan": "free" }, + "child": { "country": "de" }, + "parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" }, + "expected": { "country": "de", "plan": "free", "region": "eu" } + } + }, + "defaults": { + "presenceBased": true, + "values": ["", 0, false, null], + "aggregateEvaluationPreservesEmptyVariation": true, + "aggregateCase": { + "datafile": { + "schemaVersion": "2", + "revision": "defaults", + "segments": {}, + "features": { + "experiment": { + "key": "experiment", + "bucketBy": "userId", + "variations": [{ "value": "control" }], + "traffic": [] + } + } + }, + "defaultVariationValue": "", + "expected": { + "enabled": false, + "variation": "" + } + } + }, + "diagnosticCase": { + "featureKey": "missing", + "expectedLevel": "warn", + "expectedCode": "feature_not_found", + "detailsMustBeObject": true + }, + "nativeContexts": { + "numericTypesUseOneComparisonContract": true, + "primitiveNativeSlicesSupportIncludes": true } } diff --git a/conformance_test.go b/conformance_test.go index 00c6a6a..9d44977 100644 --- a/conformance_test.go +++ b/conformance_test.go @@ -18,6 +18,34 @@ type conformanceFixture struct { Value interface{} `json:"value"` Valid bool `json:"valid"` } `json:"typedVariables"` + NumericBucketKeys []struct { + Value float64 `json:"value"` + Expected string `json:"expected"` + } `json:"numericBucketKeys"` + RegularExpressions struct { + PortableCases []struct { + Pattern string `json:"pattern"` + Flags string `json:"flags"` + Value string `json:"value"` + Expected bool `json:"expected"` + } `json:"portableCases"` + } `json:"regularExpressions"` + ConditionCases []struct { + Name string `json:"name"` + Condition json.RawMessage `json:"condition"` + Context Context `json:"context"` + Expected bool `json:"expected"` + } `json:"conditionCases"` + Defaults struct { + AggregateCase struct { + Datafile DatafileContent `json:"datafile"` + DefaultVariationValue string `json:"defaultVariationValue"` + Expected struct { + Enabled bool `json:"enabled"` + Variation string `json:"variation"` + } `json:"expected"` + } `json:"aggregateCase"` + } `json:"defaults"` } func loadConformanceFixture(t *testing.T) conformanceFixture { @@ -35,13 +63,25 @@ func loadConformanceFixture(t *testing.T) conformanceFixture { func TestSDKV3ConformanceFixture(t *testing.T) { fixture := loadConformanceFixture(t) - if fixture.Version != 1 { + if fixture.Version != 2 { t.Fatalf("unexpected fixture version %d", fixture.Version) } - reader := newDatafileReader(datafileReaderOptions{Datafile: DatafileContent{ + for _, item := range fixture.NumericBucketKeys { + actual := getBucketKey(getBucketKeyOptions{ + FeatureKey: "feature", + BucketBy: "value", + Context: Context{"value": item.Value}, + diagnosticReporter: newDiagnosticReporter(diagnosticReporterOptions{}), + }) + if actual != item.Expected+".feature" { + t.Fatalf("numeric bucket key %#v: expected %s.feature, got %s", item.Value, item.Expected, actual) + } + } + + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{Datafile: DatafileContent{ SchemaVersion: "2", Revision: "conformance", Segments: map[SegmentKey]Segment{}, Features: map[FeatureKey]Feature{}, - }, featurevisorLogger: newLogger(loggerOptions{})}) + }, diagnosticReporter: newDiagnosticReporter(diagnosticReporterOptions{})}) traffic := &Traffic{Allocation: fixture.Bucketing.Allocations} for bucket, expected := range fixture.Bucketing.AllocationExpectations { var bucketValue int @@ -55,9 +95,49 @@ func TestSDKV3ConformanceFixture(t *testing.T) { } for _, item := range fixture.TypedVariables { - actual := GetValueByType(item.Value, item.Type) + actual := getValueByType(item.Value, item.Type) if (actual != nil) != item.Valid { t.Fatalf("type %s value %#v: expected valid=%v, got %#v", item.Type, item.Value, item.Valid, actual) } } + + for _, item := range fixture.RegularExpressions.PortableCases { + flags := item.Flags + condition := PlainCondition{ + Attribute: "value", + Operator: OperatorMatches, + Value: conditionValue(item.Pattern), + RegexFlags: &flags, + } + actual := reader.AllConditionsAreMatched(condition, Context{"value": item.Value}) + if actual != item.Expected { + t.Fatalf("regex %q flags %q value %q: expected %v, got %v", item.Pattern, item.Flags, item.Value, item.Expected, actual) + } + } + + for _, item := range fixture.ConditionCases { + var condition Condition + if err := json.Unmarshal(item.Condition, &condition); err != nil { + t.Fatalf("condition %q could not be decoded: %v", item.Name, err) + } + actual := reader.AllConditionsAreMatched(condition, item.Context) + if actual != item.Expected { + t.Fatalf("condition %q: expected %v, got %v", item.Name, item.Expected, actual) + } + } + + defaultVariation := VariationValue(fixture.Defaults.AggregateCase.DefaultVariationValue) + instance := CreateFeaturevisor(FeaturevisorOptions{ + Datafile: fixture.Defaults.AggregateCase.Datafile, + }) + actualDefault := instance.GetAllEvaluations( + Context{}, + nil, + OverrideOptions{DefaultVariationValue: &defaultVariation}, + )[FeatureKey("experiment")] + if actualDefault.Enabled != fixture.Defaults.AggregateCase.Expected.Enabled || + actualDefault.Variation == nil || + string(*actualDefault.Variation) != fixture.Defaults.AggregateCase.Expected.Variation { + t.Fatalf("aggregate default variation mismatch: %#v", actualDefault) + } } diff --git a/diagnostics.go b/diagnostics.go index 5600d2c..39ba012 100644 --- a/diagnostics.go +++ b/diagnostics.go @@ -1,7 +1,5 @@ package featurevisor -const FeaturevisorDiagnosticPrefix = "[Featurevisor]" - // FeaturevisorDiagnostic is emitted by the SDK and modules for logs/errors. type FeaturevisorDiagnostic struct { Level LogLevel `json:"level"` @@ -19,8 +17,7 @@ type FeaturevisorModuleReportedDiagnostic = FeaturevisorDiagnostic // FeaturevisorDiagnosticHandler handles diagnostics. type FeaturevisorDiagnosticHandler func(diagnostic FeaturevisorDiagnostic) -// FeaturevisorDiagnosticReporter reports diagnostics, optionally from a source module. -type FeaturevisorDiagnosticReporter func( +type featurevisorDiagnosticReporter func( diagnostic FeaturevisorDiagnostic, sourceModule *FeaturevisorModule, ) @@ -34,6 +31,15 @@ type FeaturevisorModuleDiagnosticOptions struct { type FeaturevisorUnsubscribe func() func shouldLogDiagnostic(currentLevel LogLevel, targetLevel LogLevel) bool { - logger := newLogger(loggerOptions{Level: ¤tLevel}) - return logger.shouldHandle(targetLevel) + currentIndex := -1 + targetIndex := -1 + for index, level := range allLevels { + if level == currentLevel { + currentIndex = index + } + if level == targetLevel { + targetIndex = index + } + } + return currentIndex != -1 && targetIndex != -1 && targetIndex <= currentIndex } diff --git a/logger.go b/diagnostics_reporter.go similarity index 61% rename from logger.go rename to diagnostics_reporter.go index b145acf..74d1196 100644 --- a/logger.go +++ b/diagnostics_reporter.go @@ -22,20 +22,20 @@ type logMessage string // logDetails represents additional details for logging type logDetails map[string]interface{} -// logHandler is a function type for handling log messages -type logHandler func(level LogLevel, message logMessage, details logDetails) +// diagnosticOutputHandler is a function type for handling log messages +type diagnosticOutputHandler func(level LogLevel, message logMessage, details logDetails) -// loggerOptions contains options for creating a logger -type loggerOptions struct { +// diagnosticReporterOptions contains options for creating a diagnostics +type diagnosticReporterOptions struct { Level *LogLevel - Handler *logHandler + Handler *diagnosticOutputHandler } -// loggerPrefix is the prefix used for all log messages -const loggerPrefix = "[Featurevisor]" +// diagnosticPrefix is the prefix used for all log messages +const diagnosticPrefix = "[Featurevisor]" -// defaultLogHandler is the default logging handler -func defaultLogHandler(level LogLevel, message logMessage, details logDetails) { +// defaultDiagnosticHandler is the default logging handler +func defaultDiagnosticHandler(level LogLevel, message logMessage, details logDetails) { var method string switch level { @@ -52,7 +52,7 @@ func defaultLogHandler(level LogLevel, message logMessage, details logDetails) { } // Format the log message - logMessage := fmt.Sprintf("%s %s: %s", loggerPrefix, method, message) + logMessage := fmt.Sprintf("%s %s: %s", diagnosticPrefix, method, message) // Add details if provided if len(details) > 0 { @@ -76,10 +76,11 @@ func defaultLogHandler(level LogLevel, message logMessage, details logDetails) { } } -// featurevisorLogger provides logging functionality -type featurevisorLogger struct { +// diagnosticReporter provides logging functionality +type diagnosticReporter struct { level LogLevel - handle logHandler + handle diagnosticOutputHandler + filter bool } // allLevels contains all available log levels in order of severity @@ -94,36 +95,39 @@ var allLevels = []LogLevel{ // defaultLevel is the default logging level var defaultLevel = LogLevelInfo -// newLogger creates a new logger instance -func newLogger(options loggerOptions) *featurevisorLogger { +// newDiagnosticReporter creates a new diagnostics instance +func newDiagnosticReporter(options diagnosticReporterOptions) *diagnosticReporter { level := defaultLevel if options.Level != nil { level = *options.Level } - handler := defaultLogHandler + handler := defaultDiagnosticHandler + filter := true if options.Handler != nil { handler = *options.Handler + filter = false } - return &featurevisorLogger{ + return &diagnosticReporter{ level: level, handle: handler, + filter: filter, } } // SetLevel sets the logging level -func (l *featurevisorLogger) SetLevel(level LogLevel) { +func (l *diagnosticReporter) SetLevel(level LogLevel) { l.level = level } // GetLevel returns the current logging level -func (l *featurevisorLogger) GetLevel() LogLevel { +func (l *diagnosticReporter) GetLevel() LogLevel { return l.level } // shouldHandle checks if a log level should be handled based on current level -func (l *featurevisorLogger) shouldHandle(level LogLevel) bool { +func (l *diagnosticReporter) shouldHandle(level LogLevel) bool { currentIndex := -1 targetIndex := -1 @@ -146,9 +150,11 @@ func (l *featurevisorLogger) shouldHandle(level LogLevel) bool { return targetIndex <= currentIndex } -// Log logs a message at the specified level -func (l *featurevisorLogger) Log(level LogLevel, message logMessage, details logDetails) { - if !l.shouldHandle(level) { +// Log forwards an evaluator diagnostic to the instance pipeline. +// Filtering happens only in Featurevisor.reportDiagnostic so module +// subscriptions, the main handler, and error events are independent. +func (l *diagnosticReporter) Log(level LogLevel, message logMessage, details logDetails) { + if l.filter && !l.shouldHandle(level) { return } @@ -160,26 +166,26 @@ func (l *featurevisorLogger) Log(level LogLevel, message logMessage, details log } // Debug logs a debug message -func (l *featurevisorLogger) Debug(message logMessage, details logDetails) { +func (l *diagnosticReporter) Debug(message logMessage, details logDetails) { l.Log(LogLevelDebug, message, details) } // Info logs an info message -func (l *featurevisorLogger) Info(message logMessage, details logDetails) { +func (l *diagnosticReporter) Info(message logMessage, details logDetails) { l.Log(LogLevelInfo, message, details) } // Warn logs a warning message -func (l *featurevisorLogger) Warn(message logMessage, details logDetails) { +func (l *diagnosticReporter) Warn(message logMessage, details logDetails) { l.Log(LogLevelWarn, message, details) } // Error logs an error message -func (l *featurevisorLogger) Error(message logMessage, details logDetails) { +func (l *diagnosticReporter) Error(message logMessage, details logDetails) { l.Log(LogLevelError, message, details) } // Fatal logs a fatal message and exits -func (l *featurevisorLogger) Fatal(message logMessage, details logDetails) { +func (l *diagnosticReporter) Fatal(message logMessage, details logDetails) { l.Log(LogLevelFatal, message, details) } diff --git a/logger_test.go b/diagnostics_reporter_test.go similarity index 75% rename from logger_test.go rename to diagnostics_reporter_test.go index e6e5d1b..4bf8062 100644 --- a/logger_test.go +++ b/diagnostics_reporter_test.go @@ -46,39 +46,39 @@ func TestAllLevelsOrder(t *testing.T) { } } -func TestNewLogger(t *testing.T) { - // Test default logger - logger := newLogger(loggerOptions{}) - if logger.GetLevel() != defaultLevel { - t.Errorf("Default level = %s, expected %s", logger.GetLevel(), defaultLevel) +func TestNewDiagnosticReporter(t *testing.T) { + // Test default diagnostics + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) + if diagnostics.GetLevel() != defaultLevel { + t.Errorf("Default level = %s, expected %s", diagnostics.GetLevel(), defaultLevel) } - // Test logger with custom level + // Test diagnostics with custom level customLevel := LogLevelDebug - logger = newLogger(loggerOptions{ + diagnostics = newDiagnosticReporter(diagnosticReporterOptions{ Level: &customLevel, }) - if logger.GetLevel() != customLevel { - t.Errorf("Custom level = %s, expected %s", logger.GetLevel(), customLevel) + if diagnostics.GetLevel() != customLevel { + t.Errorf("Custom level = %s, expected %s", diagnostics.GetLevel(), customLevel) } } func TestLoggerSetLevel(t *testing.T) { - logger := newLogger(loggerOptions{}) + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) // Test setting different levels testLevels := []LogLevel{LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError, LogLevelFatal} for _, level := range testLevels { - logger.SetLevel(level) - if logger.GetLevel() != level { - t.Errorf("Set level = %s, but GetLevel() returned %s", level, logger.GetLevel()) + diagnostics.SetLevel(level) + if diagnostics.GetLevel() != level { + t.Errorf("Set level = %s, but GetLevel() returned %s", level, diagnostics.GetLevel()) } } } func TestLoggerShouldHandle(t *testing.T) { - logger := newLogger(loggerOptions{}) + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) tests := []struct { name string @@ -144,8 +144,8 @@ func TestLoggerShouldHandle(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - logger.SetLevel(tt.currentLevel) - result := logger.shouldHandle(tt.targetLevel) + diagnostics.SetLevel(tt.currentLevel) + result := diagnostics.shouldHandle(tt.targetLevel) if result != tt.shouldHandle { t.Errorf("shouldHandle(%s) with level %s = %v, expected %v", tt.targetLevel, tt.currentLevel, result, tt.shouldHandle) @@ -197,7 +197,7 @@ func TestDefaultLogHandler(t *testing.T) { t.Run(string(tc.level), func(t *testing.T) { buf.Reset() - defaultLogHandler(tc.level, tc.message, tc.details) + defaultDiagnosticHandler(tc.level, tc.message, tc.details) output := buf.String() if !strings.Contains(output, tc.expect) { @@ -217,7 +217,7 @@ func TestLoggerMethods(t *testing.T) { defer log.SetOutput(os.Stderr) level := LogLevelInfo - logger := newLogger(loggerOptions{ + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{ Level: &level, }) @@ -230,28 +230,28 @@ func TestLoggerMethods(t *testing.T) { { name: "Debug", method: func() { - logger.Debug("debug message", logDetails{"debug": true}) + diagnostics.Debug("debug message", logDetails{"debug": true}) }, expect: "", // Debug messages should be filtered out at info level }, { name: "Info", method: func() { - logger.Info("info message", logDetails{"info": true}) + diagnostics.Info("info message", logDetails{"info": true}) }, expect: "info", }, { name: "Warn", method: func() { - logger.Warn("warn message", logDetails{"warn": true}) + diagnostics.Warn("warn message", logDetails{"warn": true}) }, expect: "warn", }, { name: "Error", method: func() { - logger.Error("error message", logDetails{"error": true}) + diagnostics.Error("error message", logDetails{"error": true}) }, expect: "error", }, @@ -283,20 +283,20 @@ func TestCustomLogHandler(t *testing.T) { var capturedMessage logMessage var capturedDetails logDetails - var customHandler logHandler = func(level LogLevel, message logMessage, details logDetails) { + var customHandler diagnosticOutputHandler = func(level LogLevel, message logMessage, details logDetails) { capturedLevel = level capturedMessage = message capturedDetails = details } - logger := newLogger(loggerOptions{ + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{ Handler: &customHandler, }) expectedMessage := logMessage("test message") expectedDetails := logDetails{"key": "value"} - logger.Info(expectedMessage, expectedDetails) + diagnostics.Info(expectedMessage, expectedDetails) if capturedLevel != LogLevelInfo { t.Errorf("Captured level = %s, expected %s", capturedLevel, LogLevelInfo) @@ -311,26 +311,26 @@ func TestCustomLogHandler(t *testing.T) { func TestInternalLoggerFactory(t *testing.T) { // Test with no options - logger := newLogger(loggerOptions{}) - if logger.GetLevel() != defaultLevel { - t.Errorf("CreateLogger default level = %s, expected %s", logger.GetLevel(), defaultLevel) + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) + if diagnostics.GetLevel() != defaultLevel { + t.Errorf("newDiagnosticReporter default level = %s, expected %s", diagnostics.GetLevel(), defaultLevel) } // Test with custom options customLevel := LogLevelDebug - logger = newLogger(loggerOptions{ + diagnostics = newDiagnosticReporter(diagnosticReporterOptions{ Level: &customLevel, }) - if logger.GetLevel() != customLevel { - t.Errorf("CreateLogger custom level = %s, expected %s", logger.GetLevel(), customLevel) + if diagnostics.GetLevel() != customLevel { + t.Errorf("newDiagnosticReporter custom level = %s, expected %s", diagnostics.GetLevel(), customLevel) } } func BenchmarkLoggerInfo(b *testing.B) { - logger := newLogger(loggerOptions{}) + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) b.ResetTimer() for i := 0; i < b.N; i++ { - logger.Info("benchmark message", logDetails{"benchmark": i}) + diagnostics.Info("benchmark message", logDetails{"benchmark": i}) } } diff --git a/emitter.go b/emitter.go index d13d77d..81f742f 100644 --- a/emitter.go +++ b/emitter.go @@ -21,21 +21,21 @@ type EventDetails map[string]interface{} // EventCallback is a function type for handling events type EventCallback func(details EventDetails) -// ListenerEntry represents a listener with a unique ID -type ListenerEntry struct { +// listenerEntry represents a listener with a unique ID +type listenerEntry struct { ID int Callback EventCallback } -// Listeners represents a map of event names to their listener entries -type Listeners map[EventName][]ListenerEntry +// listenersByEvent represents a map of event names to their listener entries +type listenersByEvent map[EventName][]listenerEntry // Unsubscribe is a function type for unsubscribing from events type Unsubscribe func() // emitter provides event handling functionality type emitter struct { - listeners Listeners + listeners listenersByEvent nextID int mu sync.RWMutex } @@ -43,7 +43,7 @@ type emitter struct { // newEmitter creates a new emitter instance func newEmitter() *emitter { return &emitter{ - listeners: make(Listeners), + listeners: make(listenersByEvent), nextID: 1, } } @@ -55,10 +55,10 @@ func (e *emitter) On(eventName EventName, callback EventCallback) Unsubscribe { defer e.mu.Unlock() if e.listeners[eventName] == nil { - e.listeners[eventName] = make([]ListenerEntry, 0) + e.listeners[eventName] = make([]listenerEntry, 0) } - entry := ListenerEntry{ + entry := listenerEntry{ ID: e.nextID, Callback: callback, } @@ -111,7 +111,7 @@ func (e *emitter) Trigger(eventName EventName, details EventDetails) { } // Create a copy of the listeners slice to avoid issues if callbacks modify the slice - listenersCopy := make([]ListenerEntry, len(listeners)) + listenersCopy := make([]listenerEntry, len(listeners)) copy(listenersCopy, listeners) e.mu.RUnlock() @@ -136,7 +136,7 @@ func (e *emitter) TriggerDefault(eventName EventName) { func (e *emitter) ClearAll() { e.mu.Lock() defer e.mu.Unlock() - e.listeners = make(Listeners) + e.listeners = make(listenersByEvent) } // GetListenerCount returns the number of listeners for a specific event diff --git a/evaluate.go b/evaluate.go index 5a57304..b246dec 100644 --- a/evaluate.go +++ b/evaluate.go @@ -5,40 +5,41 @@ import ( "sort" ) -// EvaluateParams contains parameters for evaluation -type EvaluateParams struct { +// evaluateParams contains parameters for evaluation +type evaluateParams struct { Type EvaluationType FeatureKey FeatureKey VariableKey *VariableKey } -// EvaluateDependencies contains dependencies for evaluation -type EvaluateDependencies struct { - Context Context - featurevisorLogger *featurevisorLogger - modulesManager *modulesManager - datafileReader *datafileReader +// evaluateDependencies contains dependencies for evaluation +type evaluateDependencies struct { + Context Context + diagnosticReporter *diagnosticReporter + modulesManager *modulesManager + instanceEvaluationDataProvider *instanceEvaluationDataProvider // Instance-internal sticky state. Consumers configure it on an instance. sticky *StickyFeatures - DefaultVariationValue *VariationValue - DefaultVariableValue VariableValue + DefaultVariationValue *VariationValue + DefaultVariableValue VariableValue + DefaultVariableValueSet bool } // EvaluateOptions contains all options for evaluation type EvaluateOptions struct { - EvaluateParams - EvaluateDependencies + evaluateParams + evaluateDependencies } -// EvaluateWithModules evaluates a feature with modules. -func EvaluateWithModules(opts EvaluateOptions) Evaluation { +// evaluateWithModules evaluates a feature with modules. +func evaluateWithModules(opts EvaluateOptions) Evaluation { var evaluation Evaluation defer func() { if r := recover(); r != nil { - opts.featurevisorLogger.Error("panic during evaluation", logDetails{ + opts.diagnosticReporter.Error("panic during evaluation", logDetails{ "error": r, }) @@ -65,7 +66,7 @@ func EvaluateWithModules(opts EvaluateOptions) Evaluation { } // evaluate - evaluation = Evaluate(options) + evaluation = evaluate(options) // default: variation if opts.DefaultVariationValue != nil && @@ -75,7 +76,7 @@ func EvaluateWithModules(opts EvaluateOptions) Evaluation { } // default: variable - if opts.DefaultVariableValue != nil && + if opts.DefaultVariableValueSet && evaluation.Type == EvaluationTypeVariable && evaluation.VariableValue == nil { evaluation.VariableValue = opts.DefaultVariableValue @@ -91,14 +92,14 @@ func EvaluateWithModules(opts EvaluateOptions) Evaluation { return evaluation } -// Evaluate evaluates a feature -func Evaluate(options EvaluateOptions) Evaluation { +// evaluate evaluates a feature +func evaluate(options EvaluateOptions) Evaluation { var evaluation Evaluation defer func() { if r := recover(); r != nil { // Log the panic and return an error evaluation - options.featurevisorLogger.Error("panic in evaluate", logDetails{ + options.diagnosticReporter.Error("panic in evaluate", logDetails{ "panic": r, }) @@ -114,7 +115,7 @@ func Evaluate(options EvaluateOptions) Evaluation { }() // feature not found - feature := options.datafileReader.GetFeature(options.FeatureKey) + feature := options.instanceEvaluationDataProvider.GetFeature(options.FeatureKey) if feature == nil { evaluation = Evaluation{ Type: options.Type, @@ -122,16 +123,14 @@ func Evaluate(options EvaluateOptions) Evaluation { Reason: EvaluationReasonFeatureNotFound, } - options.featurevisorLogger.Warn("feature not found", logDetails{ - "featureKey": options.FeatureKey, - }) + options.diagnosticReporter.Warn("feature not found", logDetails{"evaluation": evaluation}) return evaluation } // feature: deprecated if options.Type == EvaluationTypeFlag && feature.Deprecated != nil && *feature.Deprecated { - options.featurevisorLogger.Warn("feature is deprecated", logDetails{ + options.diagnosticReporter.Warn("feature is deprecated", logDetails{ "featureKey": options.FeatureKey, }) } @@ -151,7 +150,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{true}[0], } - options.featurevisorLogger.Debug("using sticky enabled", logDetails{ + options.diagnosticReporter.Debug("using sticky enabled", logDetails{ "evaluation": evaluation, }) @@ -169,7 +168,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariationValue: &variationValue, } - options.featurevisorLogger.Debug("using sticky variation", logDetails{ + options.diagnosticReporter.Debug("using sticky variation", logDetails{ "evaluation": evaluation, }) @@ -188,7 +187,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableValue, } - options.featurevisorLogger.Debug("using sticky variable", logDetails{ + options.diagnosticReporter.Debug("using sticky variable", logDetails{ "evaluation": evaluation, }) @@ -217,7 +216,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableKey: options.VariableKey, } - options.featurevisorLogger.Warn("variable schema not found", logDetails{ + options.diagnosticReporter.Warn("variable schema not found", logDetails{ "evaluation": evaluation, }) @@ -225,7 +224,7 @@ func Evaluate(options EvaluateOptions) Evaluation { } if variableSchema.Deprecated != nil && *variableSchema.Deprecated { - options.featurevisorLogger.Warn("variable is deprecated", logDetails{ + options.diagnosticReporter.Warn("variable is deprecated", logDetails{ "featureKey": options.FeatureKey, "variableKey": *options.VariableKey, }) @@ -240,7 +239,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Reason: EvaluationReasonNoVariations, } - options.featurevisorLogger.Warn("no variations", logDetails{ + options.diagnosticReporter.Warn("no variations", logDetails{ "evaluation": evaluation, }) @@ -253,12 +252,12 @@ func Evaluate(options EvaluateOptions) Evaluation { var flag Evaluation if options.Type != EvaluationTypeFlag { // needed by variation and variable evaluations - flag = Evaluate(EvaluateOptions{ - EvaluateParams: EvaluateParams{ + flag = evaluate(EvaluateOptions{ + evaluateParams: evaluateParams{ Type: EvaluationTypeFlag, FeatureKey: options.FeatureKey, }, - EvaluateDependencies: options.EvaluateDependencies, + evaluateDependencies: options.evaluateDependencies, }) if flag.Enabled != nil && !*flag.Enabled { @@ -310,7 +309,7 @@ func Evaluate(options EvaluateOptions) Evaluation { } } - options.featurevisorLogger.Debug("feature is disabled", logDetails{ + options.diagnosticReporter.Debug("feature is disabled", logDetails{ "evaluation": evaluation, }) @@ -321,7 +320,7 @@ func Evaluate(options EvaluateOptions) Evaluation { /** * Forced */ - forceResult := options.datafileReader.GetMatchedForce(feature, options.Context) + forceResult := options.instanceEvaluationDataProvider.GetMatchedForce(feature, options.Context) if forceResult.Force != nil { force := forceResult.Force @@ -338,7 +337,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: force.Enabled, } - options.featurevisorLogger.Debug("forced enabled found", logDetails{ + options.diagnosticReporter.Debug("forced enabled found", logDetails{ "evaluation": evaluation, }) @@ -359,7 +358,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariationValue: &variation.Value, } - options.featurevisorLogger.Debug("forced variation found", logDetails{ + options.diagnosticReporter.Debug("forced variation found", logDetails{ "evaluation": evaluation, }) @@ -382,7 +381,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableValue, } - options.featurevisorLogger.Debug("forced variable", logDetails{ + options.diagnosticReporter.Debug("forced variable", logDetails{ "evaluation": evaluation, }) @@ -408,12 +407,12 @@ func Evaluate(options EvaluateOptions) Evaluation { requiredVariation = &requiredWithVar.Variation } - requiredEvaluation := Evaluate(EvaluateOptions{ - EvaluateParams: EvaluateParams{ + requiredEvaluation := evaluate(EvaluateOptions{ + evaluateParams: evaluateParams{ Type: EvaluationTypeFlag, FeatureKey: requiredKey, }, - EvaluateDependencies: options.EvaluateDependencies, + evaluateDependencies: options.evaluateDependencies, }) requiredIsEnabled := requiredEvaluation.Enabled != nil && *requiredEvaluation.Enabled @@ -423,12 +422,12 @@ func Evaluate(options EvaluateOptions) Evaluation { } if requiredVariation != nil { - requiredVariationEvaluation := Evaluate(EvaluateOptions{ - EvaluateParams: EvaluateParams{ + requiredVariationEvaluation := evaluate(EvaluateOptions{ + evaluateParams: evaluateParams{ Type: EvaluationTypeVariation, FeatureKey: requiredKey, }, - EvaluateDependencies: options.EvaluateDependencies, + evaluateDependencies: options.evaluateDependencies, }) var requiredVariationValue *VariationValue @@ -455,7 +454,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{requiredFeaturesAreEnabled}[0], } - options.featurevisorLogger.Debug("required features not enabled", logDetails{ + options.diagnosticReporter.Debug("required features not enabled", logDetails{ "evaluation": evaluation, }) @@ -467,11 +466,11 @@ func Evaluate(options EvaluateOptions) Evaluation { * Bucketing */ // bucketKey - bucketKey := GetBucketKey(GetBucketKeyOptions{ + bucketKey := getBucketKey(getBucketKeyOptions{ FeatureKey: options.FeatureKey, BucketBy: feature.BucketBy, Context: options.Context, - featurevisorLogger: options.featurevisorLogger, + diagnosticReporter: options.diagnosticReporter, }) for _, module := range options.modulesManager.GetAll() { @@ -486,7 +485,7 @@ func Evaluate(options EvaluateOptions) Evaluation { } // bucketValue - bucketValue := GetBucketedNumber(bucketKey) + bucketValue := getBucketedNumber(bucketKey) for _, module := range options.modulesManager.GetAll() { if module.BucketValue != nil { @@ -503,13 +502,13 @@ func Evaluate(options EvaluateOptions) Evaluation { var matchedAllocation *Allocation if options.Type != EvaluationTypeFlag { - matchedTraffic = options.datafileReader.GetMatchedTraffic(feature.Traffic, options.Context) + matchedTraffic = options.instanceEvaluationDataProvider.GetMatchedTraffic(feature.Traffic, options.Context) if matchedTraffic != nil { - matchedAllocation = options.datafileReader.GetMatchedAllocation(matchedTraffic, bucketValue) + matchedAllocation = options.instanceEvaluationDataProvider.GetMatchedAllocation(matchedTraffic, bucketValue) } } else { - matchedTraffic = options.datafileReader.GetMatchedTraffic(feature.Traffic, options.Context) + matchedTraffic = options.instanceEvaluationDataProvider.GetMatchedTraffic(feature.Traffic, options.Context) } if matchedTraffic != nil { @@ -526,7 +525,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{false}[0], } - options.featurevisorLogger.Debug("matched rule with 0 percentage", logDetails{ + options.diagnosticReporter.Debug("matched rule with 0 percentage", logDetails{ "evaluation": evaluation, }) @@ -563,7 +562,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &enabled, } - options.featurevisorLogger.Debug("matched", logDetails{ + options.diagnosticReporter.Debug("matched", logDetails{ "evaluation": evaluation, }) @@ -580,7 +579,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{false}[0], } - options.featurevisorLogger.Debug("not matched", logDetails{ + options.diagnosticReporter.Debug("not matched", logDetails{ "evaluation": evaluation, }) @@ -600,7 +599,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: matchedTraffic.Enabled, } - options.featurevisorLogger.Debug("override from rule", logDetails{ + options.diagnosticReporter.Debug("override from rule", logDetails{ "evaluation": evaluation, }) @@ -620,7 +619,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{true}[0], } - options.featurevisorLogger.Debug("matched traffic", logDetails{ + options.diagnosticReporter.Debug("matched traffic", logDetails{ "evaluation": evaluation, }) @@ -646,7 +645,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariationValue: &variation.Value, } - options.featurevisorLogger.Debug("override from rule", logDetails{ + options.diagnosticReporter.Debug("override from rule", logDetails{ "evaluation": evaluation, }) @@ -692,7 +691,7 @@ func Evaluate(options EvaluateOptions) Evaluation { startRange := currentWeight * 100000 / totalWeight endRange := (currentWeight + weightInt) * 100000 / totalWeight - options.featurevisorLogger.Debug("checking variation weight range", logDetails{ + options.diagnosticReporter.Debug("checking variation weight range", logDetails{ "variationValue": vw.value, "weight": weightInt, "startRange": startRange, @@ -717,7 +716,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariationValue: &variation.Value, } - options.featurevisorLogger.Debug("allocated variation with custom weights", logDetails{ + options.diagnosticReporter.Debug("allocated variation with custom weights", logDetails{ "evaluation": evaluation, }) @@ -746,7 +745,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariationValue: &variation.Value, } - options.featurevisorLogger.Debug("allocated variation", logDetails{ + options.diagnosticReporter.Debug("allocated variation", logDetails{ "evaluation": evaluation, }) @@ -770,7 +769,7 @@ func Evaluate(options EvaluateOptions) Evaluation { BucketValue: &bucketValue, } - options.featurevisorLogger.Debug("variable schema not found", logDetails{ + options.diagnosticReporter.Debug("variable schema not found", logDetails{ "evaluation": evaluation, }) @@ -785,11 +784,11 @@ func Evaluate(options EvaluateOptions) Evaluation { matched := false if override.Conditions != nil { - parsedConditions := options.datafileReader.parseConditionsIfStringified(override.Conditions) - matched = options.datafileReader.AllConditionsAreMatched(parsedConditions, options.Context) + parsedConditions := options.instanceEvaluationDataProvider.parseConditionsIfStringified(override.Conditions) + matched = options.instanceEvaluationDataProvider.AllConditionsAreMatched(parsedConditions, options.Context) } else if override.Segments != nil { - parsedSegments := options.datafileReader.parseSegmentsIfStringified(override.Segments) - matched = options.datafileReader.AllSegmentsAreMatched(parsedSegments, options.Context) + parsedSegments := options.instanceEvaluationDataProvider.parseSegmentsIfStringified(override.Segments) + matched = options.instanceEvaluationDataProvider.AllSegmentsAreMatched(parsedSegments, options.Context) } if matched { @@ -808,7 +807,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableOverrideIndex: &overrideIndex, } - options.featurevisorLogger.Debug("variable override from rule", logDetails{ + options.diagnosticReporter.Debug("variable override from rule", logDetails{ "evaluation": evaluation, }) @@ -833,7 +832,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableValue, } - options.featurevisorLogger.Debug("override from rule", logDetails{ + options.diagnosticReporter.Debug("override from rule", logDetails{ "evaluation": evaluation, }) @@ -862,12 +861,12 @@ func Evaluate(options EvaluateOptions) Evaluation { matched := false if override.Conditions != nil { - parsedConditions := options.datafileReader.parseConditionsIfStringified(override.Conditions) - matched = options.datafileReader.AllConditionsAreMatched(parsedConditions, options.Context) + parsedConditions := options.instanceEvaluationDataProvider.parseConditionsIfStringified(override.Conditions) + matched = options.instanceEvaluationDataProvider.AllConditionsAreMatched(parsedConditions, options.Context) } else if override.Segments != nil { // Parse segments if they come from JSON unmarshaling - parsedSegments := options.datafileReader.parseSegmentsIfStringified(override.Segments) - matched = options.datafileReader.AllSegmentsAreMatched(parsedSegments, options.Context) + parsedSegments := options.instanceEvaluationDataProvider.parseSegmentsIfStringified(override.Segments) + matched = options.instanceEvaluationDataProvider.AllSegmentsAreMatched(parsedSegments, options.Context) } if matched { @@ -891,7 +890,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableOverrideIndex: &overrideIndex, } - options.featurevisorLogger.Debug("variable override from variation", logDetails{ + options.diagnosticReporter.Debug("variable override from variation", logDetails{ "evaluation": evaluation, }) @@ -921,7 +920,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableValue, } - options.featurevisorLogger.Debug("allocated variable", logDetails{ + options.diagnosticReporter.Debug("allocated variable", logDetails{ "evaluation": evaluation, }) @@ -945,7 +944,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableSchema.DefaultValue, } - options.featurevisorLogger.Debug("using default value", logDetails{ + options.diagnosticReporter.Debug("using default value", logDetails{ "evaluation": evaluation, }) @@ -962,7 +961,7 @@ func Evaluate(options EvaluateOptions) Evaluation { BucketValue: &bucketValue, } - options.featurevisorLogger.Debug("variable not found", logDetails{ + options.diagnosticReporter.Debug("variable not found", logDetails{ "evaluation": evaluation, }) @@ -981,7 +980,7 @@ func Evaluate(options EvaluateOptions) Evaluation { BucketValue: &bucketValue, } - options.featurevisorLogger.Debug("no matched variation", logDetails{ + options.diagnosticReporter.Debug("no matched variation", logDetails{ "evaluation": evaluation, }) @@ -1001,7 +1000,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableSchema.DefaultValue, } - options.featurevisorLogger.Debug("using default value", logDetails{ + options.diagnosticReporter.Debug("using default value", logDetails{ "evaluation": evaluation, }) @@ -1017,7 +1016,7 @@ func Evaluate(options EvaluateOptions) Evaluation { BucketValue: &bucketValue, } - options.featurevisorLogger.Debug("variable not found", logDetails{ + options.diagnosticReporter.Debug("variable not found", logDetails{ "evaluation": evaluation, }) @@ -1033,7 +1032,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{false}[0], } - options.featurevisorLogger.Debug("nothing matched", logDetails{ + options.diagnosticReporter.Debug("nothing matched", logDetails{ "evaluation": evaluation, }) diff --git a/datafile_reader.go b/evaluation_data_provider.go similarity index 73% rename from datafile_reader.go rename to evaluation_data_provider.go index 13eacc3..412d2b9 100644 --- a/datafile_reader.go +++ b/evaluation_data_provider.go @@ -5,38 +5,40 @@ import ( "fmt" "regexp" "strings" + "sync" ) -// datafileReaderOptions contains options for creating a datafile reader -type datafileReaderOptions struct { +// instanceEvaluationDataProviderOptions contains options for creating a datafile reader +type instanceEvaluationDataProviderOptions struct { Datafile DatafileContent - featurevisorLogger *featurevisorLogger + diagnosticReporter *diagnosticReporter } -// ForceResult represents the result of a force lookup -type ForceResult struct { +// forceResult represents the result of a force lookup +type forceResult struct { Force *Force `json:"force,omitempty"` ForceIndex *int `json:"forceIndex,omitempty"` } -// datafileReader provides functionality to read and query datafile content -type datafileReader struct { +// instanceEvaluationDataProvider provides functionality to read and query datafile content +type instanceEvaluationDataProvider struct { schemaVersion string revision string segments map[SegmentKey]Segment features map[FeatureKey]Feature - logger *featurevisorLogger + diagnostics *diagnosticReporter regexCache map[string]*regexp.Regexp + regexCacheMu sync.RWMutex } -// newDatafileReader creates a new datafile reader instance -func newDatafileReader(options datafileReaderOptions) *datafileReader { - return &datafileReader{ +// newInstanceEvaluationDataProvider creates a new datafile reader instance +func newInstanceEvaluationDataProvider(options instanceEvaluationDataProviderOptions) *instanceEvaluationDataProvider { + return &instanceEvaluationDataProvider{ schemaVersion: options.Datafile.SchemaVersion, revision: options.Datafile.Revision, segments: options.Datafile.Segments, features: options.Datafile.Features, - logger: options.featurevisorLogger, + diagnostics: options.diagnosticReporter, regexCache: make(map[string]*regexp.Regexp), } } @@ -45,31 +47,31 @@ func newDatafileReader(options datafileReaderOptions) *datafileReader { // It mirrors the JavaScript SDK's narrow root helper export without exposing the // internal datafile reader implementation. func AllConditionsAreMatched(conditions Condition, context Context) bool { - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: DatafileContent{ SchemaVersion: "2", Revision: "matcher", Segments: make(map[SegmentKey]Segment), Features: make(map[FeatureKey]Feature), }, - featurevisorLogger: newLogger(loggerOptions{}), + diagnosticReporter: newDiagnosticReporter(diagnosticReporterOptions{}), }) return reader.AllConditionsAreMatched(conditions, context) } // GetRevision returns the revision of the datafile -func (d *datafileReader) GetRevision() string { +func (d *instanceEvaluationDataProvider) GetRevision() string { return d.revision } // GetSchemaVersion returns the schema version of the datafile -func (d *datafileReader) GetSchemaVersion() string { +func (d *instanceEvaluationDataProvider) GetSchemaVersion() string { return d.schemaVersion } // GetSegment returns a segment by its key -func (d *datafileReader) GetSegment(segmentKey SegmentKey) *Segment { +func (d *instanceEvaluationDataProvider) GetSegment(segmentKey SegmentKey) *Segment { segment, exists := d.segments[segmentKey] if !exists { @@ -82,7 +84,7 @@ func (d *datafileReader) GetSegment(segmentKey SegmentKey) *Segment { } // GetFeatureKeys returns all feature keys -func (d *datafileReader) GetFeatureKeys() []string { +func (d *instanceEvaluationDataProvider) GetFeatureKeys() []string { keys := make([]string, 0, len(d.features)) for key := range d.features { keys = append(keys, string(key)) @@ -91,7 +93,7 @@ func (d *datafileReader) GetFeatureKeys() []string { } // GetFeature returns a feature by its key -func (d *datafileReader) GetFeature(featureKey FeatureKey) *Feature { +func (d *instanceEvaluationDataProvider) GetFeature(featureKey FeatureKey) *Feature { feature, exists := d.features[featureKey] if !exists { return nil @@ -106,7 +108,7 @@ func (d *datafileReader) GetFeature(featureKey FeatureKey) *Feature { } // GetVariableKeys returns the variable keys for a feature -func (d *datafileReader) GetVariableKeys(featureKey FeatureKey) []string { +func (d *instanceEvaluationDataProvider) GetVariableKeys(featureKey FeatureKey) []string { feature := d.GetFeature(featureKey) if feature == nil || feature.VariablesSchema == nil { @@ -121,7 +123,7 @@ func (d *datafileReader) GetVariableKeys(featureKey FeatureKey) []string { } // HasVariations checks if a feature has variations -func (d *datafileReader) HasVariations(featureKey FeatureKey) bool { +func (d *instanceEvaluationDataProvider) HasVariations(featureKey FeatureKey) bool { feature := d.GetFeature(featureKey) if feature == nil { @@ -131,34 +133,56 @@ func (d *datafileReader) HasVariations(featureKey FeatureKey) bool { return feature.Variations != nil && len(feature.Variations) > 0 } -// GetRegex returns a regex pattern with caching -func (d *datafileReader) GetRegex(regexString string, regexFlags string) *regexp.Regexp { +func (d *instanceEvaluationDataProvider) getRegex(regexString string, regexFlags string) *regexp.Regexp { flags := regexFlags - if flags == "" { - flags = "" - } cacheKey := fmt.Sprintf("%s-%s", regexString, flags) - if d.regexCache[cacheKey] != nil { - return d.regexCache[cacheKey] + d.regexCacheMu.RLock() + cached := d.regexCache[cacheKey] + d.regexCacheMu.RUnlock() + if cached != nil { + return cached } - regex := regexp.MustCompile(regexString) + goFlags := "" + for _, flag := range flags { + switch flag { + case 'i', 'm', 's': + if !strings.ContainsRune(goFlags, flag) { + goFlags += string(flag) + } + case 'g', 'u', 'y': + // JavaScript-only state/unicode flags need no Go equivalent. + default: + panic(fmt.Sprintf("invalid regular expression flag: %c", flag)) + } + } + pattern := regexString + if goFlags != "" { + pattern = "(?" + goFlags + ")" + regexString + } + regex := regexp.MustCompile(pattern) + d.regexCacheMu.Lock() + if cached = d.regexCache[cacheKey]; cached != nil { + d.regexCacheMu.Unlock() + return cached + } d.regexCache[cacheKey] = regex + d.regexCacheMu.Unlock() return regex } // AllConditionsAreMatched checks if all conditions are matched given a context -func (d *datafileReader) AllConditionsAreMatched(conditions Condition, context Context) bool { +func (d *instanceEvaluationDataProvider) AllConditionsAreMatched(conditions Condition, context Context) bool { // Add error handling wrapper like in TypeScript version defer func() { if r := recover(); r != nil { - d.logger.Warn("Error in condition matching", logDetails{ - "error": r, - "conditions": conditions, - "context": context, + d.diagnostics.Warn("Error in condition matching", logDetails{ + "error": r, + "condition": conditions, + "context": context, }) } }() @@ -174,10 +198,10 @@ func (d *datafileReader) AllConditionsAreMatched(conditions Condition, context C // Handle plain conditions if plainCondition, ok := conditions.(PlainCondition); ok { getRegex := func(regexString string, regexFlags string) *regexp.Regexp { - return d.GetRegex(regexString, regexFlags) + return d.getRegex(regexString, regexFlags) } - matched := ConditionIsMatched(plainCondition, context, getRegex) + matched := conditionIsMatched(plainCondition, context, getRegex) return matched } @@ -195,27 +219,32 @@ func (d *datafileReader) AllConditionsAreMatched(conditions Condition, context C Value: nil, // exists/notExists don't have values } getRegex := func(regexString string, regexFlags string) *regexp.Regexp { - return d.GetRegex(regexString, regexFlags) + return d.getRegex(regexString, regexFlags) } - matched := ConditionIsMatched(plainCondition, context, getRegex) + matched := conditionIsMatched(plainCondition, context, getRegex) return matched } // Handle operators that have a value if value, ok := conditionMap["value"]; ok { conditionValue := ConditionValue(value) + var regexFlags *string + if flags, ok := conditionMap["regexFlags"].(string); ok { + regexFlags = &flags + } plainCondition := PlainCondition{ - Attribute: AttributeKey(attribute), - Operator: Operator(operator), - Value: &conditionValue, + Attribute: AttributeKey(attribute), + Operator: Operator(operator), + Value: &conditionValue, + RegexFlags: regexFlags, } getRegex := func(regexString string, regexFlags string) *regexp.Regexp { - return d.GetRegex(regexString, regexFlags) + return d.getRegex(regexString, regexFlags) } // Add error handling like in TypeScript version - matched := ConditionIsMatched(plainCondition, context, getRegex) + matched := conditionIsMatched(plainCondition, context, getRegex) return matched } } @@ -293,16 +322,16 @@ func (d *datafileReader) AllConditionsAreMatched(conditions Condition, context C } // SegmentIsMatched checks if a segment is matched given a context -func (d *datafileReader) SegmentIsMatched(segment *Segment, context Context) bool { +func (d *instanceEvaluationDataProvider) SegmentIsMatched(segment *Segment, context Context) bool { return d.AllConditionsAreMatched(segment.Conditions, context) } // AllSegmentsAreMatched checks if all segments are matched given a context -func (d *datafileReader) AllSegmentsAreMatched(groupSegments interface{}, context Context) bool { +func (d *instanceEvaluationDataProvider) AllSegmentsAreMatched(groupSegments interface{}, context Context) bool { // Add error handling wrapper like in TypeScript version defer func() { if r := recover(); r != nil { - d.logger.Warn("Error in segment matching", logDetails{ + d.diagnostics.Warn("Error in segment matching", logDetails{ "error": r, "groupSegments": groupSegments, "context": context, @@ -311,7 +340,7 @@ func (d *datafileReader) AllSegmentsAreMatched(groupSegments interface{}, contex }() // Handle wildcard if groupSegments == "*" { - d.logger.Debug("matched wildcard segment", logDetails{ + d.diagnostics.Debug("matched wildcard segment", logDetails{ "segments": groupSegments, }) return true @@ -322,7 +351,7 @@ func (d *datafileReader) AllSegmentsAreMatched(groupSegments interface{}, contex segment := d.GetSegment(SegmentKey(segmentKey)) if segment != nil { matched := d.SegmentIsMatched(segment, context) - d.logger.Debug("checked single segment", logDetails{ + d.diagnostics.Debug("checked single segment", logDetails{ "segment": segmentKey, "matched": matched, }) @@ -407,18 +436,18 @@ func (d *datafileReader) AllSegmentsAreMatched(groupSegments interface{}, contex } } - d.logger.Debug("no segments matched", logDetails{ + d.diagnostics.Debug("no segments matched", logDetails{ "segments": groupSegments, }) return false } // GetMatchedTraffic returns the matched traffic for a given context -func (d *datafileReader) GetMatchedTraffic(traffic []Traffic, context Context) *Traffic { +func (d *instanceEvaluationDataProvider) GetMatchedTraffic(traffic []Traffic, context Context) *Traffic { for _, t := range traffic { segments := d.parseSegmentsIfStringified(t.Segments) if d.AllSegmentsAreMatched(segments, context) { - d.logger.Debug("matched traffic rule", logDetails{ + d.diagnostics.Debug("matched traffic rule", logDetails{ "ruleKey": t.Key, "segments": t.Segments, }) @@ -429,7 +458,7 @@ func (d *datafileReader) GetMatchedTraffic(traffic []Traffic, context Context) * } // GetMatchedAllocation returns the matched allocation for a given bucket value -func (d *datafileReader) GetMatchedAllocation(traffic *Traffic, bucketValue int) *Allocation { +func (d *instanceEvaluationDataProvider) GetMatchedAllocation(traffic *Traffic, bucketValue int) *Allocation { if traffic.Allocation == nil { return nil } @@ -447,8 +476,8 @@ func (d *datafileReader) GetMatchedAllocation(traffic *Traffic, bucketValue int) } // GetMatchedForce returns the matched force for a given feature and context -func (d *datafileReader) GetMatchedForce(featureKey interface{}, context Context) ForceResult { - result := ForceResult{ +func (d *instanceEvaluationDataProvider) GetMatchedForce(featureKey interface{}, context Context) forceResult { + result := forceResult{ Force: nil, ForceIndex: nil, } @@ -494,7 +523,7 @@ func (d *datafileReader) GetMatchedForce(featureKey interface{}, context Context } // parseConditionsIfStringified parses conditions if they are stringified -func (d *datafileReader) parseConditionsIfStringified(conditions Condition) Condition { +func (d *instanceEvaluationDataProvider) parseConditionsIfStringified(conditions Condition) Condition { if conditionStr, ok := conditions.(string); ok { if conditionStr == "*" { return conditions @@ -503,7 +532,7 @@ func (d *datafileReader) parseConditionsIfStringified(conditions Condition) Cond var parsedCondition Condition err := json.Unmarshal([]byte(conditionStr), &parsedCondition) if err != nil { - d.logger.Error("Error parsing conditions", logDetails{ + d.diagnostics.Error("Error parsing conditions", logDetails{ "error": err, "conditions": conditionStr, }) @@ -517,13 +546,13 @@ func (d *datafileReader) parseConditionsIfStringified(conditions Condition) Cond } // parseSegmentsIfStringified parses segments if they are stringified -func (d *datafileReader) parseSegmentsIfStringified(segments interface{}) interface{} { +func (d *instanceEvaluationDataProvider) parseSegmentsIfStringified(segments interface{}) interface{} { if segmentStr, ok := segments.(string); ok { if strings.HasPrefix(segmentStr, "{") || strings.HasPrefix(segmentStr, "[") { var parsedSegments interface{} err := json.Unmarshal([]byte(segmentStr), &parsedSegments) if err != nil { - d.logger.Error("Error parsing segments", logDetails{ + d.diagnostics.Error("Error parsing segments", logDetails{ "error": err, "segments": segmentStr, }) @@ -537,7 +566,7 @@ func (d *datafileReader) parseSegmentsIfStringified(segments interface{}) interf } // parseRequiredIfStringified parses required features if they are stringified -func (d *datafileReader) parseRequiredIfStringified(required []Required) []Required { +func (d *instanceEvaluationDataProvider) parseRequiredIfStringified(required []Required) []Required { parsedRequired := make([]Required, len(required)) for i, req := range required { diff --git a/datafile_reader_test.go b/evaluation_data_provider_test.go similarity index 85% rename from datafile_reader_test.go rename to evaluation_data_provider_test.go index 51662c2..6c39e65 100644 --- a/datafile_reader_test.go +++ b/evaluation_data_provider_test.go @@ -1,11 +1,42 @@ package featurevisor import ( + "sync" "testing" ) -func TestNewDatafileReader(t *testing.T) { - logger := newLogger(loggerOptions{}) +func TestConcurrentConditionEvaluationsShareRegexCacheSafely(t *testing.T) { + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ + Datafile: DatafileContent{ + SchemaVersion: "2", + Revision: "concurrent", + Segments: map[SegmentKey]Segment{}, + Features: map[FeatureKey]Feature{}, + }, + diagnosticReporter: newDiagnosticReporter(diagnosticReporterOptions{}), + }) + condition := map[string]interface{}{ + "attribute": "browser", + "operator": "matches", + "value": "^chrome$", + "regexFlags": "i", + } + + var waitGroup sync.WaitGroup + for index := 0; index < 100; index++ { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + if !reader.AllConditionsAreMatched(condition, Context{"browser": "Chrome"}) { + t.Error("expected concurrent condition to match") + } + }() + } + waitGroup.Wait() +} + +func TestNewInstanceEvaluationDataProvider(t *testing.T) { + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "test-revision", @@ -18,13 +49,13 @@ func TestNewDatafileReader(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: datafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) if reader == nil { - t.Error("newDatafileReader should return a non-nil reader") + t.Error("newInstanceEvaluationDataProvider should return a non-nil reader") } if reader.GetRevision() != "test-revision" { @@ -36,8 +67,8 @@ func TestNewDatafileReader(t *testing.T) { } } -func TestDatafileReaderGetRegex(t *testing.T) { - logger := newLogger(loggerOptions{}) +func TestInstanceEvaluationDataProviderGetRegex(t *testing.T) { + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "test-revision", @@ -50,28 +81,28 @@ func TestDatafileReaderGetRegex(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: datafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) // Test regex caching - regex1 := reader.GetRegex("test", "") - regex2 := reader.GetRegex("test", "") + regex1 := reader.getRegex("test", "") + regex2 := reader.getRegex("test", "") if regex1 != regex2 { t.Error("GetRegex should return the same regex object for the same pattern") } // Test different patterns - regex3 := reader.GetRegex("test2", "") + regex3 := reader.getRegex("test2", "") if regex1 == regex3 { t.Error("GetRegex should return different regex objects for different patterns") } } -func TestDatafileReaderAllConditionsAreMatched(t *testing.T) { - logger := newLogger(loggerOptions{}) +func TestInstanceEvaluationDataProviderAllConditionsAreMatched(t *testing.T) { + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "test-revision", @@ -84,9 +115,9 @@ func TestDatafileReaderAllConditionsAreMatched(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: datafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) context := Context{ @@ -177,9 +208,9 @@ func TestDatafileReaderAllConditionsAreMatched(t *testing.T) { } } -// TestDatafileReaderComprehensive tests comprehensive datafile reader functionality -func TestDatafileReaderComprehensive(t *testing.T) { - logger := newLogger(loggerOptions{}) +// TestInstanceEvaluationDataProviderComprehensive tests comprehensive datafile reader functionality +func TestInstanceEvaluationDataProviderComprehensive(t *testing.T) { + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) // Create a comprehensive datafile with segments and features jsonDatafile := `{ @@ -236,9 +267,9 @@ func TestDatafileReaderComprehensive(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: datafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) t.Run("basic functionality", func(t *testing.T) { @@ -358,8 +389,8 @@ func TestDatafileReaderComprehensive(t *testing.T) { }) } -func TestDatafileReaderSegmentMatching(t *testing.T) { - logger := newLogger(loggerOptions{}) +func TestInstanceEvaluationDataProviderSegmentMatching(t *testing.T) { + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) // Create segments for comprehensive testing jsonDatafile := `{ @@ -391,9 +422,9 @@ func TestDatafileReaderSegmentMatching(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: datafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) t.Run("dutch mobile users", func(t *testing.T) { @@ -534,8 +565,8 @@ func TestDatafileReaderSegmentMatching(t *testing.T) { }) } -func TestDatafileReaderForceMatching(t *testing.T) { - logger := newLogger(loggerOptions{}) +func TestInstanceEvaluationDataProviderForceMatching(t *testing.T) { + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "1", @@ -583,9 +614,9 @@ func TestDatafileReaderForceMatching(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: datafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) t.Run("force by conditions", func(t *testing.T) { @@ -631,8 +662,8 @@ func TestDatafileReaderForceMatching(t *testing.T) { }) } -func TestDatafileReaderStringifiedParsing(t *testing.T) { - logger := newLogger(loggerOptions{}) +func TestInstanceEvaluationDataProviderStringifiedParsing(t *testing.T) { + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "1", @@ -650,9 +681,9 @@ func TestDatafileReaderStringifiedParsing(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: datafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) t.Run("parse stringified conditions", func(t *testing.T) { @@ -717,8 +748,8 @@ func TestDatafileReaderStringifiedParsing(t *testing.T) { }) } -func TestDatafileReaderErrorHandling(t *testing.T) { - logger := newLogger(loggerOptions{}) +func TestInstanceEvaluationDataProviderErrorHandling(t *testing.T) { + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "1", @@ -736,9 +767,9 @@ func TestDatafileReaderErrorHandling(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := newDatafileReader(datafileReaderOptions{ + reader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: datafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) t.Run("handle invalid JSON in conditions", func(t *testing.T) { diff --git a/events.go b/events.go index f141bbe..59334ac 100644 --- a/events.go +++ b/events.go @@ -2,28 +2,28 @@ package featurevisor // getParamsForDatafileSetEvent gets parameters for datafile set event func getParamsForDatafileSetEvent( - previousDatafileReader *datafileReader, - newDatafileReader *datafileReader, + previousInstanceEvaluationDataProvider *instanceEvaluationDataProvider, + newInstanceEvaluationDataProvider *instanceEvaluationDataProvider, replace bool, ) logDetails { previousRevision := "" - if previousDatafileReader != nil { - previousRevision = previousDatafileReader.GetRevision() + if previousInstanceEvaluationDataProvider != nil { + previousRevision = previousInstanceEvaluationDataProvider.GetRevision() } newRevision := "" - if newDatafileReader != nil { - newRevision = newDatafileReader.GetRevision() + if newInstanceEvaluationDataProvider != nil { + newRevision = newInstanceEvaluationDataProvider.GetRevision() } previousFeatureKeys := []string{} - if previousDatafileReader != nil { - previousFeatureKeys = previousDatafileReader.GetFeatureKeys() + if previousInstanceEvaluationDataProvider != nil { + previousFeatureKeys = previousInstanceEvaluationDataProvider.GetFeatureKeys() } newFeatureKeys := []string{} - if newDatafileReader != nil { - newFeatureKeys = newDatafileReader.GetFeatureKeys() + if newInstanceEvaluationDataProvider != nil { + newFeatureKeys = newInstanceEvaluationDataProvider.GetFeatureKeys() } // Find removed features @@ -47,8 +47,8 @@ func getParamsForDatafileSetEvent( for _, newFeatureKey := range newFeatureKeys { if previousFeatureKey == newFeatureKey { // Check if feature was changed by comparing hashes - previousFeature := previousDatafileReader.GetFeature(FeatureKey(previousFeatureKey)) - newFeature := newDatafileReader.GetFeature(FeatureKey(newFeatureKey)) + previousFeature := previousInstanceEvaluationDataProvider.GetFeature(FeatureKey(previousFeatureKey)) + newFeature := newInstanceEvaluationDataProvider.GetFeature(FeatureKey(newFeatureKey)) if previousFeature != nil && newFeature != nil { // Compare hashes if available, otherwise assume changed diff --git a/events_parity_test.go b/events_parity_test.go index 3594a19..11a5ee8 100644 --- a/events_parity_test.go +++ b/events_parity_test.go @@ -3,10 +3,10 @@ package featurevisor import "testing" func TestGetParamsForDatafileSetEventShape(t *testing.T) { - logger := newLogger(loggerOptions{}) + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{}) - previousReader := newDatafileReader(datafileReaderOptions{ - featurevisorLogger: logger, + previousReader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ + diagnosticReporter: diagnostics, Datafile: DatafileContent{ SchemaVersion: "2", Revision: "1", @@ -16,8 +16,8 @@ func TestGetParamsForDatafileSetEventShape(t *testing.T) { }, }, }) - newReader := newDatafileReader(datafileReaderOptions{ - featurevisorLogger: logger, + newReader := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ + diagnosticReporter: diagnostics, Datafile: DatafileContent{ SchemaVersion: "2", Revision: "2", diff --git a/go.mod b/go.mod index ba96ff3..00fb434 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/featurevisor/featurevisor-go +module github.com/featurevisor/featurevisor-go/v2 go 1.21.3 diff --git a/go.work b/go.work index d9f2345..767c526 100644 --- a/go.work +++ b/go.work @@ -1,6 +1,3 @@ -go 1.22.0 +go 1.21.3 -use ( - . - ./cmd -) +use . diff --git a/helpers.go b/helpers.go index 24a243e..a6d4aaf 100644 --- a/helpers.go +++ b/helpers.go @@ -7,9 +7,9 @@ import ( "reflect" ) -// GetValueByType converts a value to the specified type +// getValueByType converts a value to the specified type // This function mirrors the TypeScript getValueByType function -func GetValueByType(value interface{}, fieldType string) interface{} { +func getValueByType(value interface{}, fieldType string) interface{} { if value == nil { return nil } @@ -82,7 +82,7 @@ func convertToTypedValue[T any](value interface{}) (T, bool) { return converted, true } -func ToTypedArray[T any](value interface{}) []T { +func toTypedArray[T any](value interface{}) []T { if value == nil { return nil } @@ -108,7 +108,7 @@ func ToTypedArray[T any](value interface{}) []T { return result } -func ToTypedObject[T any](value interface{}) *T { +func toTypedObject[T any](value interface{}) *T { if value == nil { return nil } diff --git a/helpers_parity_test.go b/helpers_parity_test.go index 9c35bab..826f78b 100644 --- a/helpers_parity_test.go +++ b/helpers_parity_test.go @@ -3,25 +3,25 @@ package featurevisor import "testing" func TestGetValueByTypeBooleanParity(t *testing.T) { - if value := GetValueByType(true, "boolean"); value != true { + if value := getValueByType(true, "boolean"); value != true { t.Fatalf("expected true bool to remain true") } - if value := GetValueByType("true", "boolean"); value != nil { + if value := getValueByType("true", "boolean"); value != nil { t.Fatalf("expected string \"true\" to not be coerced to true") } - if value := GetValueByType(1, "boolean"); value != nil { + if value := getValueByType(1, "boolean"); value != nil { t.Fatalf("expected numeric value to not be coerced to true") } } func TestGetValueByTypeDoesNotCoerceStrings(t *testing.T) { - if value := GetValueByType("1", "integer"); value != nil { + if value := getValueByType("1", "integer"); value != nil { t.Fatalf("expected nil integer, got %#v", value) } - if value := GetValueByType("1.1", "double"); value != nil { + if value := getValueByType("1.1", "double"); value != nil { t.Fatalf("expected nil double, got %#v", value) } - if value := GetValueByType(1.5, "integer"); value != nil { + if value := getValueByType(1.5, "integer"); value != nil { t.Fatalf("expected nil fractional integer, got %#v", value) } } diff --git a/instance.go b/instance.go index cf46aca..fb5ef25 100644 --- a/instance.go +++ b/instance.go @@ -9,8 +9,9 @@ import ( type OverrideOptions struct { sticky *StickyFeatures - DefaultVariationValue *VariationValue - DefaultVariableValue VariableValue + DefaultVariationValue *VariationValue + DefaultVariableValue VariableValue + DefaultVariableValueSet bool } // SpawnOptions configures a child SDK instance. @@ -39,19 +40,19 @@ type moduleDiagnosticSubscription struct { type Featurevisor struct { // from options context Context - logger *featurevisorLogger + diagnostics *diagnosticReporter logLevel LogLevel onDiagnostic FeaturevisorDiagnosticHandler sticky *StickyFeatures // internally created - datafile DatafileContent - datafileReader *datafileReader - modulesManager *modulesManager - moduleDiagnosticSubscriptions []moduleDiagnosticSubscription - nextModuleDiagnosticID int - emitter *emitter - closed bool + datafile DatafileContent + instanceEvaluationDataProvider *instanceEvaluationDataProvider + modulesManager *modulesManager + moduleDiagnosticSubscriptions []moduleDiagnosticSubscription + nextModuleDiagnosticID int + emitter *emitter + closed bool } // CreateFeaturevisor creates a new Featurevisor instance. @@ -67,12 +68,50 @@ func CreateFeaturevisor(options FeaturevisorOptions) *Featurevisor { level = *options.LogLevel } var instance *Featurevisor - handler := logHandler(func(logLevel LogLevel, message logMessage, details logDetails) { + handler := diagnosticOutputHandler(func(logLevel LogLevel, message logMessage, details logDetails) { if instance == nil { return } + normalizedDetails := make(logDetails, len(details)) + for key, value := range details { + normalizedDetails[key] = value + } + details = normalizedDetails + + var originalError interface{} + if value, ok := details["originalError"]; ok { + originalError = value + delete(details, "originalError") + } else if value, ok := details["error"]; ok { + originalError = value + delete(details, "error") + } + if nested, ok := details["details"].(map[string]interface{}); ok { + delete(details, "details") + for key, value := range nested { + details[key] = value + } + } else if nested, ok := details["details"].(logDetails); ok { + delete(details, "details") + for key, value := range nested { + details[key] = value + } + } + code := string(message) - if reason, ok := details["reason"].(EvaluationReason); ok { + if explicitCode, ok := details["code"].(string); ok { + code = explicitCode + delete(details, "code") + } + if evaluation, ok := details["evaluation"].(Evaluation); ok { + code = string(evaluation.Reason) + details = logDetails{ + "featureKey": evaluation.FeatureKey, + "variableKey": evaluation.VariableKey, + "reason": evaluation.Reason, + "evaluation": evaluation, + } + } else if reason, ok := details["reason"].(EvaluationReason); ok { code = string(reason) } else if reason, ok := details["reason"].(string); ok { code = reason @@ -89,12 +128,18 @@ func CreateFeaturevisor(options FeaturevisorOptions) *Featurevisor { code = "no_variations" } else if message == "invalid bucketBy" { code = "invalid_bucket_by" + } else if message == "Error in condition matching" { + code = "condition_match_error" + } else if message == "Error parsing conditions" { + code = "conditions_parse_error" + } else if message == "panic during evaluation" || message == "panic in evaluate" { + code = "evaluation_error" } instance.reportDiagnostic(FeaturevisorDiagnostic{ - Level: logLevel, Code: code, Message: string(message), Details: details, + Level: logLevel, Code: code, Message: string(message), OriginalError: originalError, Details: details, }, nil) }) - logger := newLogger(loggerOptions{Level: &level, Handler: &handler}) + diagnostics := newDiagnosticReporter(diagnosticReporterOptions{Level: &level, Handler: &handler}) // Create emitter emitter := newEmitter() @@ -106,20 +151,20 @@ func CreateFeaturevisor(options FeaturevisorOptions) *Featurevisor { Features: make(map[FeatureKey]Feature), } - datafileReader := newDatafileReader(datafileReaderOptions{ + instanceEvaluationDataProvider := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: emptyDatafile, - featurevisorLogger: logger, + diagnosticReporter: diagnostics, }) instance = &Featurevisor{ - context: context, - logger: logger, - logLevel: logger.GetLevel(), - onDiagnostic: options.OnDiagnostic, - emitter: emitter, - datafile: emptyDatafile, - datafileReader: datafileReader, - sticky: options.Sticky, + context: context, + diagnostics: diagnostics, + logLevel: diagnostics.GetLevel(), + onDiagnostic: options.OnDiagnostic, + emitter: emitter, + datafile: emptyDatafile, + instanceEvaluationDataProvider: instanceEvaluationDataProvider, + sticky: options.Sticky, } instance.modulesManager = newModulesManager(modulesManagerOptions{ @@ -145,7 +190,7 @@ func CreateFeaturevisor(options FeaturevisorOptions) *Featurevisor { // SetLogLevel sets the log level func (i *Featurevisor) SetLogLevel(level LogLevel) { i.logLevel = level - i.logger.SetLevel(level) + i.diagnostics.SetLevel(level) } // SetDatafile sets the datafile @@ -175,15 +220,15 @@ func (i *Featurevisor) SetDatafile(datafile interface{}, replace ...bool) { storedDatafile = mergeStoredDatafile(i.datafile, datafileContent) } - newDatafileReader := newDatafileReader(datafileReaderOptions{ + newInstanceEvaluationDataProvider := newInstanceEvaluationDataProvider(instanceEvaluationDataProviderOptions{ Datafile: storedDatafile, - featurevisorLogger: i.logger, + diagnosticReporter: i.diagnostics, }) - details := getParamsForDatafileSetEvent(i.datafileReader, newDatafileReader, replaceValue) + details := getParamsForDatafileSetEvent(i.instanceEvaluationDataProvider, newInstanceEvaluationDataProvider, replaceValue) i.datafile = storedDatafile - i.datafileReader = newDatafileReader + i.instanceEvaluationDataProvider = newInstanceEvaluationDataProvider i.reportDiagnostic(FeaturevisorDiagnostic{ Level: LogLevelInfo, @@ -237,32 +282,32 @@ func (i *Featurevisor) SetSticky(sticky StickyFeatures, replace ...bool) { // GetRevision returns the revision func (i *Featurevisor) GetRevision() string { - return i.datafileReader.GetRevision() + return i.instanceEvaluationDataProvider.GetRevision() } func (i *Featurevisor) GetSchemaVersion() string { - return i.datafileReader.GetSchemaVersion() + return i.instanceEvaluationDataProvider.GetSchemaVersion() } func (i *Featurevisor) GetSegment(segmentKey string) *Segment { - return i.datafileReader.GetSegment(SegmentKey(segmentKey)) + return i.instanceEvaluationDataProvider.GetSegment(SegmentKey(segmentKey)) } func (i *Featurevisor) GetFeatureKeys() []string { - return i.datafileReader.GetFeatureKeys() + return i.instanceEvaluationDataProvider.GetFeatureKeys() } func (i *Featurevisor) GetVariableKeys(featureKey string) []string { - return i.datafileReader.GetVariableKeys(FeatureKey(featureKey)) + return i.instanceEvaluationDataProvider.GetVariableKeys(FeatureKey(featureKey)) } func (i *Featurevisor) HasVariations(featureKey string) bool { - return i.datafileReader.HasVariations(FeatureKey(featureKey)) + return i.instanceEvaluationDataProvider.HasVariations(FeatureKey(featureKey)) } // GetFeature returns a feature by key func (i *Featurevisor) GetFeature(featureKey string) *Feature { - return i.datafileReader.GetFeature(FeatureKey(featureKey)) + return i.instanceEvaluationDataProvider.GetFeature(FeatureKey(featureKey)) } // AddModule adds a module. @@ -358,7 +403,7 @@ func (i *Featurevisor) reportDiagnostic( if diagnostic.OriginalError != nil { details["originalError"] = diagnostic.OriginalError } - defaultLogHandler(diagnostic.Level, logMessage(diagnostic.Message), details) + defaultDiagnosticHandler(diagnostic.Level, logMessage(diagnostic.Message), details) } } @@ -496,7 +541,7 @@ func (i *Featurevisor) Spawn(args ...interface{}) *FeaturevisorChild { } } - return newFeaturevisorChild(ChildOptions{ + return newFeaturevisorChild(childOptions{ Parent: i, Context: i.GetContext(contextValue), Sticky: optionsValue.Sticky, @@ -504,7 +549,7 @@ func (i *Featurevisor) Spawn(args ...interface{}) *FeaturevisorChild { } // getEvaluationDependencies gets evaluation dependencies -func (i *Featurevisor) getEvaluationDependencies(context Context, options OverrideOptions) EvaluateDependencies { +func (i *Featurevisor) getEvaluationDependencies(context Context, options OverrideOptions) evaluateDependencies { var sticky *StickyFeatures if options.sticky != nil { sticky = options.sticky @@ -512,25 +557,41 @@ func (i *Featurevisor) getEvaluationDependencies(context Context, options Overri sticky = i.sticky } - return EvaluateDependencies{ - Context: i.GetContext(context), - featurevisorLogger: i.logger, - modulesManager: i.modulesManager, - datafileReader: i.datafileReader, - sticky: sticky, - DefaultVariationValue: options.DefaultVariationValue, - DefaultVariableValue: options.DefaultVariableValue, + return evaluateDependencies{ + Context: i.GetContext(context), + diagnosticReporter: i.diagnostics, + modulesManager: i.modulesManager, + instanceEvaluationDataProvider: i.instanceEvaluationDataProvider, + sticky: sticky, + DefaultVariationValue: options.DefaultVariationValue, + DefaultVariableValue: options.DefaultVariableValue, + DefaultVariableValueSet: options.DefaultVariableValueSet, + } +} + +func parseEvaluationArgs(args []interface{}) (Context, OverrideOptions) { + context := Context{} + options := OverrideOptions{} + for _, arg := range args { + switch value := arg.(type) { + case Context: + context = value + case OverrideOptions: + options = value + } } + return context, options } -// EvaluateFlag evaluates a feature flag -func (i *Featurevisor) EvaluateFlag(featureKey string, context Context, options OverrideOptions) Evaluation { - return EvaluateWithModules(EvaluateOptions{ - EvaluateParams: EvaluateParams{ +// EvaluateFlag evaluates a feature flag. +func (i *Featurevisor) EvaluateFlag(featureKey string, args ...interface{}) Evaluation { + context, options := parseEvaluationArgs(args) + return evaluateWithModules(EvaluateOptions{ + evaluateParams: evaluateParams{ Type: EvaluationTypeFlag, FeatureKey: FeatureKey(featureKey), }, - EvaluateDependencies: i.getEvaluationDependencies(context, options), + evaluateDependencies: i.getEvaluationDependencies(context, options), }) } @@ -538,7 +599,7 @@ func (i *Featurevisor) EvaluateFlag(featureKey string, context Context, options func (i *Featurevisor) IsEnabled(featureKey string, args ...interface{}) bool { defer func() { if r := recover(); r != nil { - i.logger.Error("isEnabled", logDetails{ + i.diagnostics.Error("isEnabled", logDetails{ "featureKey": featureKey, "error": r, }) @@ -569,13 +630,14 @@ func (i *Featurevisor) IsEnabled(featureKey string, args ...interface{}) bool { } // EvaluateVariation evaluates a feature variation -func (i *Featurevisor) EvaluateVariation(featureKey string, context Context, options OverrideOptions) Evaluation { - return EvaluateWithModules(EvaluateOptions{ - EvaluateParams: EvaluateParams{ +func (i *Featurevisor) EvaluateVariation(featureKey string, args ...interface{}) Evaluation { + context, options := parseEvaluationArgs(args) + return evaluateWithModules(EvaluateOptions{ + evaluateParams: evaluateParams{ Type: EvaluationTypeVariation, FeatureKey: FeatureKey(featureKey), }, - EvaluateDependencies: i.getEvaluationDependencies(context, options), + evaluateDependencies: i.getEvaluationDependencies(context, options), }) } @@ -583,7 +645,7 @@ func (i *Featurevisor) EvaluateVariation(featureKey string, context Context, opt func (i *Featurevisor) GetVariation(featureKey string, args ...interface{}) *string { defer func() { if r := recover(); r != nil { - i.logger.Error("getVariation", logDetails{ + i.diagnostics.Error("getVariation", logDetails{ "featureKey": featureKey, "error": r, }) @@ -622,14 +684,15 @@ func (i *Featurevisor) GetVariation(featureKey string, args ...interface{}) *str } // EvaluateVariable evaluates a feature variable -func (i *Featurevisor) EvaluateVariable(featureKey string, variableKey VariableKey, context Context, options OverrideOptions) Evaluation { - return EvaluateWithModules(EvaluateOptions{ - EvaluateParams: EvaluateParams{ +func (i *Featurevisor) EvaluateVariable(featureKey string, variableKey VariableKey, args ...interface{}) Evaluation { + context, options := parseEvaluationArgs(args) + return evaluateWithModules(EvaluateOptions{ + evaluateParams: evaluateParams{ Type: EvaluationTypeVariable, FeatureKey: FeatureKey(featureKey), VariableKey: &variableKey, }, - EvaluateDependencies: i.getEvaluationDependencies(context, options), + evaluateDependencies: i.getEvaluationDependencies(context, options), }) } @@ -637,7 +700,7 @@ func (i *Featurevisor) EvaluateVariable(featureKey string, variableKey VariableK func (i *Featurevisor) GetVariable(featureKey string, variableKey string, args ...interface{}) VariableValue { defer func() { if r := recover(); r != nil { - i.logger.Error("getVariable", logDetails{ + i.diagnostics.Error("getVariable", logDetails{ "featureKey": featureKey, "variableKey": variableKey, "error": r, @@ -670,7 +733,7 @@ func (i *Featurevisor) GetVariable(featureKey string, variableKey string, args . return parsedJSON } else { // Log error if JSON parsing fails - i.logger.Error("could not parse JSON variable", logDetails{ + i.diagnostics.Error("could not parse JSON variable", logDetails{ "featureKey": featureKey, "variableKey": variableKey, "error": err, @@ -681,7 +744,7 @@ func (i *Featurevisor) GetVariable(featureKey string, variableKey string, args . // Apply type conversion for default values if evaluation.VariableSchema != nil && evaluation.Reason == EvaluationReasonVariableDefault { - return GetValueByType(evaluation.VariableValue, string(evaluation.VariableSchema.Type)) + return getValueByType(evaluation.VariableValue, string(evaluation.VariableSchema.Type)) } return evaluation.VariableValue @@ -697,7 +760,7 @@ func (i *Featurevisor) GetVariableBoolean(featureKey string, variableKey string, return nil } - typedValue := GetValueByType(value, "boolean") + typedValue := getValueByType(value, "boolean") if boolValue, ok := typedValue.(bool); ok { return &boolValue } @@ -712,7 +775,7 @@ func (i *Featurevisor) GetVariableString(featureKey string, variableKey string, return nil } - typedValue := GetValueByType(value, "string") + typedValue := getValueByType(value, "string") if stringValue, ok := typedValue.(string); ok { return &stringValue } @@ -727,7 +790,7 @@ func (i *Featurevisor) GetVariableInteger(featureKey string, variableKey string, return nil } - typedValue := GetValueByType(value, "integer") + typedValue := getValueByType(value, "integer") if intValue, ok := typedValue.(int); ok { return &intValue } @@ -742,7 +805,7 @@ func (i *Featurevisor) GetVariableDouble(featureKey string, variableKey string, return nil } - typedValue := GetValueByType(value, "double") + typedValue := getValueByType(value, "double") if floatValue, ok := typedValue.(float64); ok { return &floatValue } @@ -757,7 +820,7 @@ func (i *Featurevisor) GetVariableArray(featureKey string, variableKey string, a return nil } - return ToTypedArray[string](GetValueByType(value, "array")) + return toTypedArray[string](getValueByType(value, "array")) } // GetVariableObject gets an object variable @@ -767,7 +830,7 @@ func (i *Featurevisor) GetVariableObject(featureKey string, variableKey string, return nil } - typedValue := ToTypedObject[map[string]interface{}](GetValueByType(value, "object")) + typedValue := toTypedObject[map[string]interface{}](getValueByType(value, "object")) if typedValue == nil { return nil } @@ -799,7 +862,7 @@ func (i *Featurevisor) GetVariableArrayInto(featureKey string, variableKey strin return decodeInto(nil, out) } - arrayValue := GetValueByType(value, "array") + arrayValue := getValueByType(value, "array") if arrayValue == nil { return fmt.Errorf("variable %q is not an array", variableKey) } @@ -820,7 +883,7 @@ func (i *Featurevisor) GetVariableObjectInto(featureKey string, variableKey stri return decodeInto(nil, out) } - objectValue := GetValueByType(value, "object") + objectValue := getValueByType(value, "object") if objectValue == nil { return fmt.Errorf("variable %q is not an object", variableKey) } @@ -835,7 +898,7 @@ func (i *Featurevisor) GetAllEvaluations(context Context, featureKeys []string, keys := featureKeys if len(keys) == 0 { // Get all feature keys - allKeys := i.datafileReader.GetFeatureKeys() + allKeys := i.instanceEvaluationDataProvider.GetFeatureKeys() keys = make([]string, len(allKeys)) for j, key := range allKeys { keys[j] = string(key) @@ -849,7 +912,7 @@ func (i *Featurevisor) GetAllEvaluations(context Context, featureKeys []string, } // variation - if i.datafileReader.HasVariations(FeatureKey(featureKey)) { + if i.instanceEvaluationDataProvider.HasVariations(FeatureKey(featureKey)) { variation := i.GetVariation(featureKey, context, options) if variation != nil { evaluatedFeature.Variation = variation @@ -857,7 +920,7 @@ func (i *Featurevisor) GetAllEvaluations(context Context, featureKeys []string, } // variables - variableKeys := i.datafileReader.GetVariableKeys(FeatureKey(featureKey)) + variableKeys := i.instanceEvaluationDataProvider.GetVariableKeys(FeatureKey(featureKey)) if len(variableKeys) > 0 { evaluatedFeature.Variables = make(map[VariableKey]VariableValue) for _, variableKey := range variableKeys { diff --git a/instance_test.go b/instance_test.go index 2bc271a..b3a3044 100644 --- a/instance_test.go +++ b/instance_test.go @@ -490,6 +490,59 @@ func TestModulesSetupDiagnosticsAndClose(t *testing.T) { } } +func TestModuleDiagnosticLevelsAreIndependentFromInstanceLevel(t *testing.T) { + fatal := LogLevelFatal + observed := []FeaturevisorDiagnostic{} + instance := CreateFeaturevisor(FeaturevisorOptions{ + LogLevel: &fatal, + Modules: []*FeaturevisorModule{{ + Name: "observer", + Setup: func(api FeaturevisorModuleApi) { + api.OnDiagnostic(func(diagnostic FeaturevisorDiagnostic) { + observed = append(observed, diagnostic) + }, FeaturevisorModuleDiagnosticOptions{LogLevel: LogLevelDebug}) + }, + }}, + }) + + instance.IsEnabled("missing") + + found := false + for _, diagnostic := range observed { + if diagnostic.Code == "feature_not_found" { + found = true + if diagnostic.Details["featureKey"] != FeatureKey("missing") { + t.Fatalf("expected canonical featureKey details, got %#v", diagnostic.Details) + } + } + } + if !found { + t.Fatalf("expected debug module subscriber to receive evaluator diagnostics, got %#v", observed) + } +} + +func TestExplicitNilVariableDefaultIsApplied(t *testing.T) { + fatal := LogLevelFatal + instance := CreateFeaturevisor(FeaturevisorOptions{LogLevel: &fatal}) + + evaluation := instance.EvaluateVariable( + "missing", + "value", + Context{}, + OverrideOptions{ + DefaultVariableValue: nil, + DefaultVariableValueSet: true, + }, + ) + + if evaluation.Reason != EvaluationReasonFeatureNotFound { + t.Fatalf("expected feature_not_found, got %q", evaluation.Reason) + } + if evaluation.VariableValue != nil { + t.Fatalf("expected explicit nil default, got %#v", evaluation.VariableValue) + } +} + func TestModuleSetupFailureIsIsolated(t *testing.T) { var diagnostics []FeaturevisorDiagnostic closed := 0 @@ -988,3 +1041,58 @@ func TestLifecycleMutationsReportDiagnostics(t *testing.T) { } } } + +func TestInvalidSemverReportsPortableConditionDiagnostic(t *testing.T) { + var datafile DatafileContent + if err := datafile.FromJSON(`{ + "schemaVersion": "2", + "revision": "1", + "segments": { + "version": { + "key": "version", + "conditions": [{"attribute": "version", "operator": "semverGreaterThan", "value": "1.0.0"}] + } + }, + "features": { + "test": { + "key": "test", + "bucketBy": "userId", + "traffic": [{"key": "all", "segments": ["version"], "percentage": 100000, "enabled": true}] + } + } + }`); err != nil { + t.Fatal(err) + } + + level := LogLevelDebug + diagnostics := []FeaturevisorDiagnostic{} + instance := CreateFeaturevisor(FeaturevisorOptions{ + Datafile: &datafile, + LogLevel: &level, + OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { + diagnostics = append(diagnostics, diagnostic) + }, + }) + + if instance.IsEnabled("test", Context{"userId": "user", "version": "invalid"}) { + t.Fatal("expected invalid semantic version not to match") + } + + for _, diagnostic := range diagnostics { + if diagnostic.Code != "condition_match_error" { + continue + } + if diagnostic.OriginalError == nil { + t.Fatal("expected condition diagnostic to preserve original error") + } + if _, ok := diagnostic.Details["condition"]; !ok { + t.Fatalf("expected condition details, got %#v", diagnostic.Details) + } + if _, ok := diagnostic.Details["context"]; !ok { + t.Fatalf("expected context details, got %#v", diagnostic.Details) + } + return + } + + t.Fatalf("expected condition_match_error diagnostic, got %#v", diagnostics) +} diff --git a/modules.go b/modules.go index fa440e2..ad6c4f4 100644 --- a/modules.go +++ b/modules.go @@ -52,7 +52,7 @@ func getModuleName(module *FeaturevisorModule) string { // modulesManagerOptions contains options for creating a modules manager. type modulesManagerOptions struct { Modules []*FeaturevisorModule - ReportDiagnostic FeaturevisorDiagnosticReporter + ReportDiagnostic featurevisorDiagnosticReporter GetModuleApi func(module *FeaturevisorModule) FeaturevisorModuleApi ClearModuleDiagnosticSubscriptions func(module *FeaturevisorModule) } @@ -60,7 +60,7 @@ type modulesManagerOptions struct { // modulesManager manages Featurevisor modules. type modulesManager struct { modules []*FeaturevisorModule - reportDiagnostic FeaturevisorDiagnosticReporter + reportDiagnostic featurevisorDiagnosticReporter getModuleApi func(module *FeaturevisorModule) FeaturevisorModuleApi clearModuleDiagnosticSubscriptions func(module *FeaturevisorModule) } diff --git a/murmurhash.go b/murmurhash.go index 9cd4032..62091d7 100644 --- a/murmurhash.go +++ b/murmurhash.go @@ -2,7 +2,7 @@ package featurevisor import "fmt" -// MurmurHashV3 implements the MurmurHash v3 algorithm +// murmurHashV3 implements the MurmurHash v3 algorithm // Ported from the TypeScript implementation in the Featurevisor SDK // // Copyright (c) 2020 Gary Court, Derek Perez @@ -13,8 +13,8 @@ import "fmt" // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -// MurmurHashV3 calculates the MurmurHash v3 hash of the given key with the specified seed -func MurmurHashV3(key interface{}, seed uint32) uint32 { +// murmurHashV3 calculates the MurmurHash v3 hash of the given key with the specified seed +func murmurHashV3(key interface{}, seed uint32) uint32 { var data []byte // Convert key to byte slice diff --git a/murmurhash_test.go b/murmurhash_test.go index 0f3c697..6032723 100644 --- a/murmurhash_test.go +++ b/murmurhash_test.go @@ -51,9 +51,9 @@ func TestMurmurHashV3(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := MurmurHashV3(tt.key, tt.seed) + result := murmurHashV3(tt.key, tt.seed) if result != tt.expected { - t.Errorf("MurmurHashV3(%v, %d) = %d, want %d", tt.key, tt.seed, result, tt.expected) + t.Errorf("murmurHashV3(%v, %d) = %d, want %d", tt.key, tt.seed, result, tt.expected) } }) } @@ -64,11 +64,11 @@ func TestMurmurHashV3Consistency(t *testing.T) { key := "test_key" seed := uint32(42) - result1 := MurmurHashV3(key, seed) - result2 := MurmurHashV3(key, seed) + result1 := murmurHashV3(key, seed) + result2 := murmurHashV3(key, seed) if result1 != result2 { - t.Errorf("MurmurHashV3 is not consistent: %d != %d", result1, result2) + t.Errorf("murmurHashV3 is not consistent: %d != %d", result1, result2) } } @@ -76,11 +76,11 @@ func TestMurmurHashV3DifferentSeeds(t *testing.T) { key := "test_key" // Test that different seeds produce different results - result1 := MurmurHashV3(key, 0) - result2 := MurmurHashV3(key, 1) + result1 := murmurHashV3(key, 0) + result2 := murmurHashV3(key, 1) if result1 == result2 { - t.Errorf("MurmurHashV3 with different seeds should produce different results: %d == %d", result1, result2) + t.Errorf("murmurHashV3 with different seeds should produce different results: %d == %d", result1, result2) } } @@ -90,6 +90,6 @@ func BenchmarkMurmurHashV3(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - MurmurHashV3(key, seed) + murmurHashV3(key, seed) } } diff --git a/openfeature/go.mod b/openfeature/go.mod new file mode 100644 index 0000000..665d4a2 --- /dev/null +++ b/openfeature/go.mod @@ -0,0 +1,15 @@ +module github.com/featurevisor/featurevisor-go/openfeature/v2 + +go 1.25.0 + +require ( + github.com/featurevisor/featurevisor-go/v2 v2.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/v2 => .. 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..9f2b480 --- /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/v2" + 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..e785d63 --- /dev/null +++ b/openfeature/provider_test.go @@ -0,0 +1,149 @@ +package openfeature + +import ( + "context" + "testing" + + featurevisor "github.com/featurevisor/featurevisor-go/v2" + 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") + } +} diff --git a/public_api_test.go b/public_api_test.go new file mode 100644 index 0000000..2f1fc09 --- /dev/null +++ b/public_api_test.go @@ -0,0 +1,29 @@ +package featurevisor_test + +import ( + "testing" + + featurevisor "github.com/featurevisor/featurevisor-go/v2" +) + +func TestPublicAPIContractsCompileForExternalConsumers(t *testing.T) { + module := featurevisor.FeaturevisorModule{ + Name: "public-api", + Before: func(options featurevisor.EvaluateOptions) featurevisor.EvaluateOptions { + options.Context["fromModule"] = true + return options + }, + BucketKey: func(options featurevisor.ConfigureBucketKeyOptions) featurevisor.BucketKey { + return featurevisor.BucketKey(options.BucketKey) + }, + } + level := featurevisor.LogLevelFatal + instance := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ + LogLevel: &level, + Modules: []*featurevisor.FeaturevisorModule{&module}, + }) + defer instance.Close() + + unsubscribe := instance.On(featurevisor.EventNameError, func(details featurevisor.EventDetails) {}) + unsubscribe() +}