diff --git a/Gemfile.lock b/Gemfile.lock index b6c5195..2dedc92 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - featurevisor (2.0.0) + featurevisor (3.0.0) benchmark (>= 0, < 1) GEM diff --git a/README.md b/README.md index bb845a0..ed8c0e3 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Getting variation](#getting-variation) - [Getting variables](#getting-variables) - [Type specific methods](#type-specific-methods) -- [Getting all evaluations](#getting-all-evaluations) -- [Sticky](#sticky) +- [Getting global variables](#getting-global-variables) +- [Getting aggregate evaluations](#getting-aggregate-evaluations) +- [Sticky features and variables](#sticky-features-and-variables) - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) @@ -36,7 +37,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - - [`sticky_set`](#sticky_set) + - [`sticky_features_set` and `sticky_variables_set`](#sticky_features_set-and-sticky_variables_set) - [`error`](#error) - [Modules](#modules) - [Defining a module](#defining-a-module) @@ -89,7 +90,7 @@ f = Featurevisor.create_featurevisor( Most applications only need this factory and the returned `Featurevisor::Instance`. Public extension and observability APIs include modules, diagnostics, events, and the datafile structures accepted by the factory. -Concurrent evaluations are safe after an instance is configured. Do not mutate or close the same instance concurrently with evaluations. Serialize calls to `set_datafile`, `set_context`, `set_sticky`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. +Concurrent evaluations are safe after an instance is configured. Do not mutate or close the same instance concurrently with evaluations. Serialize calls to `set_datafile`, `set_context`, `set_sticky_features`, `set_sticky_variables`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture. ## Initialization @@ -295,12 +296,24 @@ f.get_variable_json(feature_key, variable_key, context = {}) Type specific methods do not coerce values. `get_variable_integer` returns `nil` for the string `"1"`, and boolean getters return `nil` for non-boolean values. -## Getting all evaluations +## Getting global variables + +Global variables use the same methods with a single variable key: + +```ruby +message = f.get_variable_string('welcomeMessage', context) +value = f.get_variable('checkoutSettings', context) +evaluation = f.evaluate_variable('checkoutSettings', context) +``` + +Global variables resolve sticky values first, then required features, then the first matching override, and finally their default value. + +## Getting aggregate evaluations You can get evaluations of all features available in the SDK instance: ```ruby -all_evaluations = f.get_all_evaluations({}) +all_evaluations = f.get_feature_evaluations({}) puts all_evaluations # { @@ -321,11 +334,17 @@ puts all_evaluations This is handy especially when you want to pass all evaluations from a backend application to the frontend. -## Sticky +Global variable values are available separately: + +```ruby +global_variables = f.get_variable_evaluations({}) +``` + +## Sticky features and variables For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): -Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `spawn(context, sticky: ...)` when a child needs its own sticky state. +Sticky values belong to an SDK or child instance. Feature and global variable sticky values are independent. ### Initialize with sticky @@ -333,7 +352,7 @@ Sticky values belong to an SDK or child instance. Evaluation options do not acce require 'featurevisor' f = Featurevisor.create_featurevisor( - sticky: { + sticky_features: { myFeatureKey: { enabled: true, # optional @@ -345,6 +364,9 @@ f = Featurevisor.create_featurevisor( anotherFeatureKey: { enabled: false } + }, + sticky_variables: { + welcomeMessage: 'Welcome back' } ) ``` @@ -356,7 +378,7 @@ Once initialized with sticky features, the SDK will look for values there first You can also set sticky features after the SDK is initialized: ```ruby -f.set_sticky({ +f.set_sticky_features({ myFeatureKey: { enabled: true, variation: 'treatment', @@ -368,6 +390,8 @@ f.set_sticky({ enabled: false } }, true) # replace existing sticky features (false by default) + +f.set_sticky_variables({ welcomeMessage: 'Welcome back' }, true) ``` ## Setting datafile @@ -393,7 +417,7 @@ By default, `set_datafile(datafile)` merges the incoming datafile into the SDK's - `segments` are merged, with incoming entries overriding existing ones - `features` are merged, with incoming entries overriding existing ones -This means you can call `set_datafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. +This means you can call `set_datafile` more than once with different datafiles, and the SDK instance accumulates their features, segments, and global variables together. ### Replacing @@ -545,15 +569,19 @@ unsubscribe = f.on('context_set') do |event| end ``` -### `sticky_set` +### `sticky_features_set` and `sticky_variables_set` ```ruby -unsubscribe = f.on('sticky_set') do |event| +feature_unsubscribe = f.on('sticky_features_set') do |event| replaced = event[:replaced] # true if sticky features got replaced features = event[:features] # list of all affected feature keys puts 'Sticky features set' end + +variable_unsubscribe = f.on('sticky_variables_set') do |event| + variables = event[:variables] +end ``` ### `error` @@ -583,11 +611,14 @@ evaluation = f.evaluate_variation(feature_key, context = {}) # variable evaluation = f.evaluate_variable(feature_key, variable_key, context = {}) + +# global variable +global_evaluation = f.evaluate_variable(variable_key, context = {}) ``` The returned object will always contain the following properties: -- `feature_key`: the feature key +- `feature_key`: the feature key when evaluating a feature - `reason`: the reason how the value was evaluated And optionally these properties depending on whether you are evaluating a feature variation or a variable: @@ -606,6 +637,8 @@ And optionally these properties depending on whether you are evaluating a featur Modules allow you to intercept the evaluation process and customize SDK behavior. +For feature evaluations, all `before` callbacks run in registration order, followed by all `before_evaluation` callbacks. After evaluation and caller defaults, all `after_evaluation` callbacks run, followed by all `after` callbacks. Global variable evaluations use only `before_evaluation` and `after_evaluation`. Required feature checks run through the complete module pipeline, and transformed defaults are preserved. + ### Defining a module A module is a simple hash with a unique recommended `name` and optional lifecycle functions: @@ -639,6 +672,9 @@ my_custom_module = { options }, + # unified callback for feature and global variable evaluations + before_evaluation: ->(options) { options }, + # after evaluation after: ->(evaluation, options) { reason = evaluation[:reason] @@ -648,6 +684,9 @@ my_custom_module = { end }, + # unified callback for feature and global variable evaluations + after_evaluation: ->(evaluation, options) { evaluation }, + # configure bucket key bucket_key: ->(options) { # return custom bucket key @@ -720,12 +759,14 @@ Now you can pass the child instance where your individual request is being handl is_enabled = child_f.is_enabled('my_feature') variation = child_f.get_variation('my_feature') variable_value = child_f.get_variable('my_feature', 'my_variable') +global_value = child_f.get_variable('welcomeMessage') ``` Similar to parent SDK, child instances also support several additional methods: - `set_context` -- `set_sticky` +- `set_sticky_features` +- `set_sticky_variables` - `evaluate_flag` - `is_enabled` - `evaluate_variation` @@ -739,7 +780,8 @@ Similar to parent SDK, child instances also support several additional methods: - `get_variable_array` - `get_variable_object` - `get_variable_json` -- `get_all_evaluations` +- `get_feature_evaluations` +- `get_variable_evaluations` - `on` - `close` @@ -828,7 +870,7 @@ The provider currently requires Ruby 3.4 or newer because that is the minimum ve Install the provider: ```ruby -gem "featurevisor-openfeature", "~> 2.0" +gem "featurevisor-openfeature", "~> 3.0" ``` It installs the matching `featurevisor` gem and the official `openfeature-sdk` dependency. The provider and base SDK deliberately share the same version, and the provider requires that exact Featurevisor version. @@ -852,9 +894,9 @@ enabled = client.fetch_boolean_value( ) ``` -Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Arrays, hashes, and JSON variables use the object resolver. +Use `checkout` for a flag, `checkout:variation` for its variation, `checkout:title` for its `title` variable, and `variable:welcomeMessage` for a global variable. Boolean variables use the boolean resolver. Arrays, hashes, and JSON variables use the object resolver. -OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping. +OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, `variation_key`, and `global_variable_prefix` can customize the mapping. The global variable prefix defaults to `variable` and cannot contain the separator. You can pass any Featurevisor initialization options directly to the provider. These options are used to create the Featurevisor instance owned by the provider: @@ -908,7 +950,7 @@ The build produces `featurevisor-VERSION.gem` and `featurevisor-openfeature-VERS - Run `bundle install` - Push commit to `main` branch - Wait for CI to complete -- Tag the release with the same version number, for example `v2.0.0` +- Tag the release with the same version number, for example `v3.0.0` - The workflow verifies that the tag matches the shared version - The workflow publishes `featurevisor` first, followed by `featurevisor-openfeature` diff --git a/bin/cli.rb b/bin/cli.rb index dfbf068..68e0fa7 100644 --- a/bin/cli.rb +++ b/bin/cli.rb @@ -43,7 +43,7 @@ def self.parse(args) options.environment = v end - opts.on("--feature=FEATURE", "Feature key (required for benchmark)") do |v| + opts.on("--feature=FEATURE", "Feature key") do |v| options.feature = v end @@ -150,6 +150,6 @@ def self.show_help puts " featurevisor benchmark --feature=myFeature --environment=dev --n=10000" puts " featurevisor assess-distribution --feature=myFeature --n=10000" puts "" - puts "Note: benchmark command requires --environment and --feature options" + puts "Note: benchmark requires --environment and either --feature or --variable" end end diff --git a/bin/commands/benchmark.rb b/bin/commands/benchmark.rb index bb56a48..6019939 100644 --- a/bin/commands/benchmark.rb +++ b/bin/commands/benchmark.rb @@ -22,8 +22,8 @@ def run exit 1 end - unless @options.feature - puts "Error: --feature is required for benchmark command" + unless @options.feature || @options.variable + puts "Error: --feature or --variable is required for benchmark command" exit 1 end @@ -41,7 +41,8 @@ def run_for_target(context, target) datafile_build_duration_ms = (datafile_build_duration * 1000).round puts "\nBenchmark Featurevisor feature" - puts " Feature: #{@options.feature}" + puts " Feature: #{@options.feature}" if @options.feature + puts " Global variable: #{@options.variable}" if @options.variable && !@options.feature puts " Environment: #{@options.environment}" puts " Target: #{target}" if target puts " Iterations: #{@options.n}" @@ -59,7 +60,10 @@ def run_for_target(context, target) puts "Against context: #{context.to_json}" # Run the appropriate benchmark - if @options.variation + if @options.variable && !@options.feature + puts "Evaluating global variable \"#{@options.variable}\" #{@options.n} times..." + output = benchmark_global_variable(instance, @options.variable, context, @options.n) + elsif @options.variation puts "Evaluating variation #{@options.n} times..." output = benchmark_feature_variation(instance, @options.feature, context, @options.n) elsif @options.variable @@ -223,6 +227,12 @@ def benchmark_feature_variable(instance, feature_key, variable_key, context, n) end end + def benchmark_global_variable(instance, variable_key, context, n) + benchmark_evaluation(n) do + instance.get_variable(variable_key, context) + end + end + def format_value(value) if value.nil? "null" diff --git a/bin/commands/test.rb b/bin/commands/test.rb index ca7b717..cdab652 100644 --- a/bin/commands/test.rb +++ b/bin/commands/test.rb @@ -235,10 +235,12 @@ def resolve_datafile_for_assertion(assertion, datafiles_by_key) def create_tester_instance(datafile, level, assertion) sticky = parse_sticky(assertion[:sticky]) + sticky_variables = assertion[:stickyVariables].is_a?(Hash) ? assertion[:stickyVariables] : {} Featurevisor.create_featurevisor( datafile: datafile, - sticky: sticky, + sticky_features: sticky, + sticky_variables: sticky_variables, log_level: level, modules: [ { @@ -265,7 +267,7 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) tests.each do |test| test_key = test[:key] assertions = test[:assertions] || [] - if test[:feature] && !@options.targets.empty? + if (test[:feature] || test[:variable]) && !@options.targets.empty? assertions = assertions.select do |assertion| assertion[:target].nil? || @options.targets.include?(assertion[:target]) end @@ -301,6 +303,14 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) test_result = run_test_feature(assertion, test[:feature], instance, level) end + elsif test[:variable] + datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key) + if datafile + instance = create_tester_instance(datafile, level, assertion) + test_result = run_test_variable(assertion, test[:variable], instance) + else + test_result = { has_error: true, errors: " ✘ no datafile found for assertion target/environment combination\n", duration: 0 } + end elsif test[:segment] segment_key = test[:segment] segment = segments_by_key[segment_key] @@ -347,6 +357,31 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) end end + def run_test_variable(assertion, variable_key, instance) + context = parse_context(assertion[:context]) + options = {} + options[:default_variable_value] = assertion[:defaultVariableValue] if assertion.key?(:defaultVariableValue) + started = Time.now + errors = "" + + if assertion.key?(:expectedValue) + actual = instance.get_variable(variable_key, context, options) + unless compare_values(actual, assertion[:expectedValue]) + errors += " ✘ expectedValue: expected #{assertion[:expectedValue].inspect} but received #{actual.inspect}\n" + end + end + + if assertion[:expectedEvaluation].is_a?(Hash) + evaluation = instance.evaluate_variable(variable_key, context, options) + assertion[:expectedEvaluation].each do |key, expected| + actual = get_evaluation_value(evaluation, key) + errors += " ✘ expectedEvaluation.#{key}: expected #{expected.inspect} but received #{actual.inspect}\n" unless compare_values(actual, expected) + end + end + + { has_error: !errors.empty?, errors: errors, duration: Time.now - started } + end + def run_test_feature(assertion, feature_key, instance, level) context = parse_context(assertion[:context]) sticky = parse_sticky(assertion[:sticky]) @@ -354,7 +389,7 @@ def run_test_feature(assertion, feature_key, instance, level) # Set context and sticky for this assertion instance.set_context(context, false) if sticky && !sticky.empty? - instance.set_sticky(sticky, false) + instance.set_sticky_features(sticky, false) end # Create override options @@ -492,7 +527,7 @@ def run_test_feature(assertion, feature_key, instance, level) # Create a local copy to ensure it's never nil child_sticky = sticky || {} if !child_sticky.empty? - child_instance.set_sticky(child_sticky, false) + child_instance.set_sticky_features(child_sticky, false) end child_result = run_test_feature_child(child, feature_key, child_instance, level) @@ -795,6 +830,8 @@ def get_evaluation_value(evaluation, key) evaluation[:force] when :required evaluation[:required] + when :requiredFeatures + evaluation[:required_features] when :sticky evaluation[:sticky] when :variation @@ -809,6 +846,10 @@ def get_evaluation_value(evaluation, key) evaluation[:variable_schema] when :variableOverrideIndex evaluation[:variable_override_index] + when :variableOverrideKey + evaluation[:variable_override_key] + when :variableOverridePath + evaluation[:variable_override_path] else nil end diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index 49396ce..d5870a5 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 6, "description": "Featurevisor v3 cross SDK compatibility contracts", "bucketing": { "minimum": 0, @@ -81,6 +81,594 @@ "schemaVersionIsInformational": true, "schemaVersionType": "string" }, + "globalVariables": { + "datafile": { + "schemaVersion": "2", + "revision": "global-variables", + "segments": { + "netherlands": { + "conditions": { + "attribute": "country", + "operator": "equals", + "value": "nl" + } + } + }, + "features": { + "enabledFeature": { + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledFeature": { + "bucketBy": "userId", + "traffic": [] + }, + "variationFeature": { + "bucketBy": "userId", + "variations": [{ "value": "control" }, { "value": "treatment" }], + "force": [{ "segments": "*", "enabled": true, "variation": "treatment" }], + "traffic": [] + }, + "shared": { + "bucketBy": "userId", + "variablesSchema": { + "owned": { "type": "string", "defaultValue": "feature-value" } + }, + "force": [{ "segments": "*", "enabled": true }], + "traffic": [] + } + }, + "variables": { + "shared": { "type": "string", "defaultValue": "global-value" }, + "stringValue": { "type": "string", "defaultValue": "hello" }, + "integerValue": { "type": "integer", "defaultValue": 1 }, + "doubleValue": { "type": "double", "defaultValue": 1.5 }, + "booleanValue": { "type": "boolean", "defaultValue": true }, + "arrayValue": { "type": "array", "defaultValue": ["one", "two"] }, + "objectValue": { "type": "object", "defaultValue": { "enabled": true } }, + "jsonValue": { "type": "json", "defaultValue": "{\"enabled\":true}" }, + "requiredDisabled": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "requiredFeatures": ["disabledFeature"] + }, + "requiredMissingValue": { + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["disabledFeature"] + }, + "requiredUsesDefault": { + "type": "string", + "defaultValue": "default", + "disabledValue": "disabled", + "useDefaultWhenDisabled": true, + "requiredFeatures": ["disabledFeature"] + }, + "requiredVariation": { + "type": "string", + "defaultValue": "matched", + "disabledValue": "disabled", + "requiredFeatures": [{ "feature": "variationFeature", "variation": "treatment" }] + }, + "overrideRequirement": { + "type": "string", + "defaultValue": "default", + "overrides": [ + { + "key": "blocked", + "segments": "*", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + } + ] + }, + "orderedOverrides": { + "type": "string", + "defaultValue": "default", + "overrides": [ + { + "key": "blocked", + "segments": "*", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + }, + { + "key": "nl-pro", + "keyPath": ["europe", "netherlands", "pro"], + "segments": "netherlands", + "conditions": { + "attribute": "plan", + "operator": "equals", + "value": "pro" + }, + "requiredFeatures": ["enabledFeature"], + "value": "matched" + }, + { "key": "catch-all", "segments": "*", "value": "fallback" } + ] + } + } + }, + "cases": [ + { + "name": "string default", + "key": "stringValue", + "expectedValue": "hello", + "expectedReason": "variable_default" + }, + { + "name": "integer default", + "key": "integerValue", + "expectedValue": 1, + "expectedReason": "variable_default" + }, + { + "name": "double default", + "key": "doubleValue", + "expectedValue": 1.5, + "expectedReason": "variable_default" + }, + { + "name": "boolean default", + "key": "booleanValue", + "expectedValue": true, + "expectedReason": "variable_default" + }, + { + "name": "array default", + "key": "arrayValue", + "expectedValue": ["one", "two"], + "expectedReason": "variable_default" + }, + { + "name": "object default", + "key": "objectValue", + "expectedValue": { "enabled": true }, + "expectedReason": "variable_default" + }, + { + "name": "json default", + "key": "jsonValue", + "expectedValue": "{\"enabled\":true}", + "expectedReason": "variable_default" + }, + { + "name": "required unmet with disabled value", + "key": "requiredDisabled", + "expectedValue": "disabled", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet without value", + "key": "requiredMissingValue", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet with caller default", + "key": "requiredMissingValue", + "defaultVariableValue": "caller", + "expectedValue": "caller", + "expectedReason": "required_features_unmet" + }, + { + "name": "required unmet using variable default", + "key": "requiredUsesDefault", + "expectedValue": "default", + "expectedReason": "required_features_unmet" + }, + { + "name": "required variation matched", + "key": "requiredVariation", + "expectedValue": "matched", + "expectedReason": "variable_default" + }, + { + "name": "unmet override requirement falls through", + "key": "overrideRequirement", + "expectedValue": "default", + "expectedReason": "variable_default" + }, + { + "name": "segment and condition override", + "key": "orderedOverrides", + "context": { "userId": "1", "country": "nl", "plan": "pro" }, + "expectedValue": "matched", + "expectedReason": "variable_override_rule", + "expectedOverrideIndex": 1, + "expectedOverrideKey": "nl-pro", + "expectedOverridePath": ["europe", "netherlands", "pro"] + }, + { + "name": "catch all override", + "key": "orderedOverrides", + "context": { "userId": "1", "country": "de", "plan": "pro" }, + "expectedValue": "fallback", + "expectedReason": "variable_override_rule", + "expectedOverrideIndex": 2, + "expectedOverrideKey": "catch-all" + }, + { + "name": "sticky precedence without definition", + "key": "absent", + "stickyVariables": { "absent": "sticky" }, + "expectedValue": "sticky", + "expectedReason": "sticky" + } + ], + "overloadCase": { + "sharedKey": "shared", + "featureVariableKey": "owned", + "expectedGlobalValue": "global-value", + "expectedFeatureValue": "feature-value" + }, + "datafileUpdateCase": { + "initial": { + "schemaVersion": "2", + "revision": "initial", + "segments": {}, + "features": { + "retained": { "hash": "feature-retained", "bucketBy": "userId", "traffic": [] }, + "changed": { "hash": "feature-old", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "retained": { "hash": "variable-retained", "type": "string", "defaultValue": "retained" }, + "changed": { "hash": "variable-old", "type": "string", "defaultValue": "old" } + } + }, + "merge": { + "schemaVersion": "2", + "revision": "merged", + "segments": {}, + "features": { + "changed": { "hash": "feature-new", "bucketBy": "userId", "traffic": [] }, + "added": { "hash": "feature-added", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "changed": { "hash": "variable-new", "type": "string", "defaultValue": "new" }, + "added": { "hash": "variable-added", "type": "string", "defaultValue": "added" } + } + }, + "expectedAfterMerge": { + "features": ["added", "changed", "retained"], + "variables": ["added", "changed", "retained"], + "changedFeatures": ["changed", "added"], + "changedVariables": ["changed", "added"] + }, + "replacement": { + "schemaVersion": "2", + "revision": "replaced", + "segments": {}, + "features": { + "added": { "hash": "feature-added", "bucketBy": "userId", "traffic": [] } + }, + "variables": { + "added": { "hash": "variable-added", "type": "string", "defaultValue": "added" } + } + }, + "expectedAfterReplacement": { + "features": ["added"], + "variables": ["added"], + "changedFeatures": ["retained", "changed"], + "changedVariables": ["retained", "changed"] + } + }, + "dependencyUpdateCase": { + "modes": [ + { "name": "merge", "replace": false }, + { "name": "replacement", "replace": true } + ], + "initial": { + "schemaVersion": "2", + "revision": "dependencies-initial", + "segments": { + "audience": { + "conditions": { "attribute": "country", "operator": "equals", "value": "nl" } + } + }, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-old", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "updated": { + "schemaVersion": "2", + "revision": "dependencies-updated", + "segments": { + "audience": { + "conditions": { "attribute": "country", "operator": "equals", "value": "de" } + } + }, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-new", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "withoutSegment": { + "schemaVersion": "2", + "revision": "dependencies-without-segment", + "segments": {}, + "features": { + "segmentFeature": { + "hash": "segment-feature", + "bucketBy": "userId", + "traffic": [{ "key": "audience", "segments": "audience", "percentage": 100000 }] + }, + "segmentDependent": { + "hash": "segment-dependent", + "bucketBy": "userId", + "requiredFeatures": ["segmentFeature"], + "traffic": [] + }, + "prerequisite": { + "hash": "prerequisite-old", + "bucketBy": "userId", + "traffic": [] + }, + "requiredDependent": { + "hash": "required-dependent", + "bucketBy": "userId", + "requiredFeatures": ["prerequisite"], + "traffic": [] + } + }, + "variables": { + "bySegment": { + "hash": "by-segment", + "type": "string", + "defaultValue": "default", + "overrides": [{ "key": "audience", "segments": "audience", "value": "matched" }] + }, + "bySegmentFeature": { + "hash": "by-segment-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["segmentDependent"] + }, + "byRequiredFeature": { + "hash": "by-required-feature", + "type": "string", + "defaultValue": "default", + "requiredFeatures": ["requiredDependent"] + } + } + }, + "expectedChangedFeatures": [ + "prerequisite", + "requiredDependent", + "segmentDependent", + "segmentFeature" + ], + "expectedChangedVariables": ["byRequiredFeature", "bySegment", "bySegmentFeature"], + "expectedRemovedSegmentFeatures": ["segmentDependent", "segmentFeature"], + "expectedRemovedSegmentVariables": ["bySegment", "bySegmentFeature"] + } + }, + "requiredFeatures": { + "datafile": { + "schemaVersion": "2", + "revision": "required-features", + "segments": {}, + "features": { + "enabledFeature": { + "bucketBy": "userId", + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledFeature": { "bucketBy": "userId", "traffic": [] }, + "disabledVariationFeature": { + "bucketBy": "userId", + "disabledVariationValue": "treatment", + "variations": [{ "value": "control" }, { "value": "treatment" }], + "traffic": [] + }, + "stringRequirement": { + "bucketBy": "userId", + "requiredFeatures": ["enabledFeature"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "explicitEnabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "enabledFeature", "enabled": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "disabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "disabledFeature", "enabled": false }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "missingDisabledRequirement": { + "bucketBy": "userId", + "requiredFeatures": [{ "feature": "missingFeature", "enabled": false }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "enabledAndVariationRequirement": { + "bucketBy": "userId", + "requiredFeatures": [ + { + "feature": "disabledVariationFeature", + "enabled": false, + "variation": "treatment" + } + ], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "multipleRequirements": { + "bucketBy": "userId", + "requiredFeatures": [ + "enabledFeature", + { "feature": "disabledFeature", "enabled": false } + ], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "unmetMultipleRequirements": { + "bucketBy": "userId", + "requiredFeatures": ["enabledFeature", { "feature": "disabledFeature", "enabled": true }], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "canonicalPrecedence": { + "bucketBy": "userId", + "required": ["disabledFeature"], + "requiredFeatures": ["enabledFeature"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + }, + "featureVariableOverride": { + "bucketBy": "userId", + "variablesSchema": { + "message": { "type": "string", "defaultValue": "default" } + }, + "traffic": [ + { + "key": "all", + "segments": "*", + "percentage": 100000, + "variableOverrides": { + "message": [ + { + "key": "blocked", + "requiredFeatures": ["disabledFeature"], + "value": "blocked" + }, + { + "key": "matched", + "requiredFeatures": ["enabledFeature"], + "value": "matched" + } + ] + } + } + ] + } + } + }, + "cases": [ + { + "name": "string requirement defaults to enabled", + "feature": "stringRequirement", + "expectedEnabled": true + }, + { + "name": "explicit enabled true", + "feature": "explicitEnabledRequirement", + "expectedEnabled": true + }, + { + "name": "disabled feature satisfies enabled false", + "feature": "disabledRequirement", + "expectedEnabled": true + }, + { + "name": "missing feature satisfies enabled false", + "feature": "missingDisabledRequirement", + "expectedEnabled": true + }, + { + "name": "enabled and variation both match", + "feature": "enabledAndVariationRequirement", + "expectedEnabled": true + }, + { + "name": "multiple requirements use AND", + "feature": "multipleRequirements", + "expectedEnabled": true + }, + { + "name": "one unmet requirement disables feature", + "feature": "unmetMultipleRequirements", + "expectedEnabled": false + }, + { + "name": "requiredFeatures takes precedence over required", + "feature": "canonicalPrecedence", + "expectedEnabled": true + } + ], + "featureVariableCase": { + "feature": "featureVariableOverride", + "variable": "message", + "expectedValue": "matched", + "expectedOverrideKey": "matched" + } + }, "diagnostics": { "requiredFields": ["level", "code", "message", "details"], "detailsType": "object", @@ -107,11 +695,7 @@ "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" - ], + "semanticVersions": ["1.2.3", "1.2.3-beta.1", "1.2.3+build.5"], "invalidSemanticVersion": "invalid", "invalidSemanticVersionDiagnosticCode": "condition_match_error" }, @@ -171,6 +755,7 @@ ], "childInstances": { "contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win", + "stickyStateModel": "child sticky features and variables replace parent sticky state; omitted child sticky options mean empty sticky state", "closeRemovesLocalAndDelegatedSubscriptions": true, "detailedEvaluationMethods": ["flag", "variation", "variable"], "contextCase": { @@ -178,11 +763,52 @@ "child": { "country": "de" }, "parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" }, "expected": { "country": "de", "plan": "free", "region": "eu" } + }, + "stickyCase": { + "datafile": { + "schemaVersion": "2", + "revision": "child-sticky", + "segments": {}, + "features": { + "flag": { + "key": "flag", + "bucketBy": "userId", + "traffic": [] + } + }, + "variables": { + "setting": { + "type": "string", + "defaultValue": "datafile" + } + } + }, + "parentStickyFeatures": { "flag": { "enabled": true } }, + "parentStickyVariables": { "setting": "parent-sticky" }, + "expectedParent": { "flag": true, "setting": "parent-sticky" }, + "expectedChildWithoutStickyOptions": { "flag": false, "setting": "datafile" } + }, + "globalJsonCase": { + "datafile": { + "schemaVersion": "2", + "revision": "child-global-json", + "segments": {}, + "features": {}, + "variables": { + "settings": { + "type": "json", + "defaultValue": "{\"enabled\":true}" + } + } + }, + "variableKey": "settings", + "expected": { "enabled": true } } }, "defaults": { "presenceBased": true, "values": ["", 0, false, null], + "explicitNullBeatsCallerDefault": true, "aggregateEvaluationPreservesEmptyVariation": true, "aggregateCase": { "datafile": { @@ -205,6 +831,38 @@ } } }, + "modulePipeline": { + "featureOrder": [ + "before:first", + "before:second", + "beforeEvaluation:first", + "beforeEvaluation:second", + "afterEvaluation:first", + "afterEvaluation:second", + "after:first", + "after:second" + ], + "globalOrder": [ + "beforeEvaluation:first", + "beforeEvaluation:second", + "afterEvaluation:first", + "afterEvaluation:second" + ], + "requiredFeaturesUseModules": true, + "transformedDefaultsAreApplied": true + }, + "lifecycle": { + "stickyFeatureEvent": "sticky_features_set", + "stickyFeatureDiagnostic": "sticky_features_set", + "stickyVariableEvent": "sticky_variables_set", + "stickyVariableDiagnostic": "sticky_variables_set", + "diagnosticBeforeEvent": true + }, + "openFeature": { + "reasonMappings": { + "required_features_unmet": "DISABLED" + } + }, "diagnosticCase": { "featureKey": "missing", "expectedLevel": "warn", diff --git a/gemfiles/base.gemfile.lock b/gemfiles/base.gemfile.lock index 4c9f890..55073cd 100644 --- a/gemfiles/base.gemfile.lock +++ b/gemfiles/base.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - featurevisor (2.0.0) + featurevisor (3.0.0) benchmark (>= 0, < 1) GEM diff --git a/lib/featurevisor/child_instance.rb b/lib/featurevisor/child_instance.rb index 4f3fab0..fa491a0 100644 --- a/lib/featurevisor/child_instance.rb +++ b/lib/featurevisor/child_instance.rb @@ -1,351 +1,126 @@ # frozen_string_literal: true module Featurevisor - # Child instance class for managing child contexts and sticky features + # Child instance with isolated context and sticky state. class ChildInstance - # Initialize a new child instance - # @param options [Hash] Child instance options - # @option options [Instance] :parent Parent instance - # @option options [Hash] :context Child context - # @option options [Hash] :sticky Child sticky features def initialize(options) @parent = options[:parent] @context = options[:context] || {} - @sticky = options[:sticky] || {} + @sticky_features = options[:sticky_features] || {} + @sticky_variables = options[:sticky_variables] || {} @emitter = Featurevisor::Emitter.new @parent_unsubscribers = [] end - # Subscribe to an event - # @param event_name [String] Event name - # @param callback [Proc] Callback function - # @return [Proc] Unsubscribe function def on(event_name, callback = nil, &block) callback = block if block_given? - - if event_name == "context_set" || event_name == "sticky_set" - @emitter.on(event_name, callback) - else - parent_unsubscribe = @parent.on(event_name, callback) - active = true - unsubscribe = nil - unsubscribe = proc do - next unless active + if %w[context_set sticky_features_set sticky_variables_set].include?(event_name) + return @emitter.on(event_name, callback) + end + + parent_unsubscribe = @parent.on(event_name, callback) + active = true + unsubscribe = nil + unsubscribe = proc do + next unless active - active = false - parent_unsubscribe.call - @parent_unsubscribers.delete(unsubscribe) - end - @parent_unsubscribers << unsubscribe - unsubscribe + active = false + parent_unsubscribe.call + @parent_unsubscribers.delete(unsubscribe) end + @parent_unsubscribers << unsubscribe + unsubscribe end - # Close the child instance def close @parent_unsubscribers.dup.each(&:call) @parent_unsubscribers.clear @emitter.clear_all end - # Set context - # @param context [Hash] Context to set - # @param replace [Boolean] Whether to replace existing context def set_context(context, replace = false) - if replace - @context = context - else - @context = { **@context, **context } - end - - @emitter.trigger("context_set", { - context: @context, - replaced: replace - }) + @context = replace ? context : { **@context, **context } + @emitter.trigger("context_set", context: @context, replaced: replace) end - # Get context - # @param context [Hash, nil] Additional context to merge - # @return [Hash] Merged context def get_context(context = nil) - @parent.get_context({ - **@context, - **(context || {}) - }) + @parent.get_context({ **@context, **(context || {}) }) end - # Set sticky features - # @param sticky [Hash] Sticky features - # @param replace [Boolean] Whether to replace existing sticky features - def set_sticky(sticky, replace = false) - previous_sticky_features = @sticky || {} - - if replace - @sticky = sticky - else - @sticky = { - **@sticky, - **sticky - } - end + def set_sticky_features(sticky, replace = false) + previous = @sticky_features + @sticky_features = replace ? sticky : { **@sticky_features, **sticky } + params = Featurevisor::Events.get_params_for_sticky_features_set_event(previous, @sticky_features, replace) + @emitter.trigger("sticky_features_set", params) + end - params = Featurevisor::Events.get_params_for_sticky_set_event(previous_sticky_features, @sticky, replace) - @emitter.trigger("sticky_set", params) + def set_sticky_variables(sticky, replace = false) + previous = @sticky_variables + @sticky_variables = replace ? sticky : { **@sticky_variables, **sticky } + @emitter.trigger( + "sticky_variables_set", + Featurevisor::Events.get_params_for_sticky_variables_set_event(previous, @sticky_variables, replace) + ) end - # Check if a feature is enabled - # @param feature_key [String] Feature key - # @param context [Hash] Context - # @param options [Hash] Override options - # @return [Boolean] True if feature is enabled def is_enabled(feature_key, context = {}, options = {}) - @parent.is_enabled( - feature_key, - { - **@context, - **context - }, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) + @parent.is_enabled(feature_key, child_context(context), child_options(options)) end - # Evaluate a feature flag and return its full evaluation details. def evaluate_flag(feature_key, context = {}, options = {}) - @parent.evaluate_flag( - feature_key, - { **@context, **context }, - { **options, __featurevisor_child_sticky: @sticky } - ) + @parent.evaluate_flag(feature_key, child_context(context), child_options(options)) end - # Get variation value - # @param feature_key [String] Feature key - # @param context [Hash] Context - # @param options [Hash] Override options - # @return [String, nil] Variation value or nil def get_variation(feature_key, context = {}, options = {}) - @parent.get_variation( - feature_key, - { - **@context, - **context - }, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) + @parent.get_variation(feature_key, child_context(context), child_options(options)) end - # Evaluate a variation and return its full evaluation details. def evaluate_variation(feature_key, context = {}, options = {}) - @parent.evaluate_variation( - feature_key, - { **@context, **context }, - { **options, __featurevisor_child_sticky: @sticky } - ) - end - - # Get variable value - # @param feature_key [String] Feature key - # @param variable_key [String] Variable key - # @param context [Hash] Context - # @param options [Hash] Override options - # @return [Object, nil] Variable value or nil - def get_variable(feature_key, variable_key, context = {}, options = {}) - @parent.get_variable( - feature_key, - variable_key, - { - **@context, - **context - }, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) + @parent.evaluate_variation(feature_key, child_context(context), child_options(options)) end - # Evaluate a variable and return its full evaluation details. - def evaluate_variable(feature_key, variable_key, context = {}, options = {}) - @parent.evaluate_variable( - feature_key, - variable_key, - { **@context, **context }, - { **options, __featurevisor_child_sticky: @sticky } - ) + def get_variable(feature_or_variable_key, variable_key_or_context = nil, context_or_options = {}, options = {}) + delegate_variable(:get_variable, feature_or_variable_key, variable_key_or_context, context_or_options, options) end - # Get variable as boolean - # @param feature_key [String] Feature key - # @param variable_key [String] Variable key - # @param context [Hash] Context - # @param options [Hash] Override options - # @return [Boolean, nil] Boolean value or nil - def get_variable_boolean(feature_key, variable_key, context = {}, options = {}) - @parent.get_variable_boolean( - feature_key, - variable_key, - { - **@context, - **context - }, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) + def evaluate_variable(feature_or_variable_key, variable_key_or_context = nil, context_or_options = {}, options = {}) + delegate_variable(:evaluate_variable, feature_or_variable_key, variable_key_or_context, context_or_options, options) end - # Get variable as string - # @param feature_key [String] Feature key - # @param variable_key [String] Variable key - # @param context [Hash] Context - # @param options [Hash] Override options - # @return [String, nil] String value or nil - def get_variable_string(feature_key, variable_key, context = {}, options = {}) - @parent.get_variable_string( - feature_key, - variable_key, - { - **@context, - **context - }, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) + %i[boolean string integer double array object json].each do |type| + define_method("get_variable_#{type}") do |feature_or_variable_key, variable_key_or_context = nil, context_or_options = {}, options = {}| + delegate_variable("get_variable_#{type}".to_sym, feature_or_variable_key, variable_key_or_context, context_or_options, options) + end end - # Get variable as integer - # @param feature_key [String] Feature key - # @param variable_key [String] Variable key - # @param context [Hash] Context - # @param options [Hash] Override options - # @return [Integer, nil] Integer value or nil - def get_variable_integer(feature_key, variable_key, context = {}, options = {}) - @parent.get_variable_integer( - feature_key, - variable_key, - { - **@context, - **context - }, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) + def get_feature_evaluations(context = {}, feature_keys = [], options = {}) + @parent.get_feature_evaluations(child_context(context), feature_keys, child_options(options)) end - # Get variable as double - # @param feature_key [String] Feature key - # @param variable_key [String] Variable key - # @param context [Hash] Context - # @param options [Hash] Override options - # @return [Float, nil] Float value or nil - def get_variable_double(feature_key, variable_key, context = {}, options = {}) - @parent.get_variable_double( - feature_key, - variable_key, - { - **@context, - **context - }, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) + def get_variable_evaluations(context = {}, variable_keys = [], options = {}) + @parent.get_variable_evaluations(child_context(context), variable_keys, child_options(options)) end - # Get variable as array - # @param feature_key [String] Feature key - # @param variable_key [String] Variable key - # @param context [Hash] Context - # @param options [Hash] Override options - # @return [Array, nil] Array value or nil - def get_variable_array(feature_key, variable_key, context = {}, options = {}) - @parent.get_variable_array( - feature_key, - variable_key, - { - **@context, - **context - }, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) - end + private - # Get variable as object - # @param feature_key [String] Feature key - # @param variable_key [String] Variable key - # @param context [Hash] Context - # @param options [Hash] Override options - # @return [Hash, nil] Object value or nil - def get_variable_object(feature_key, variable_key, context = {}, options = {}) - @parent.get_variable_object( - feature_key, - variable_key, - { - **@context, - **context - }, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) + def child_context(context) + { **@context, **context } end - # Get variable as JSON - # @param feature_key [String] Feature key - # @param variable_key [String] Variable key - # @param context [Hash] Context - # @param options [Hash] Override options - # @return [Object, nil] JSON value or nil - def get_variable_json(feature_key, variable_key, context = {}, options = {}) - @parent.get_variable_json( - feature_key, - variable_key, - { - **@context, - **context - }, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) + def child_options(options) + { + **options, + __featurevisor_child_sticky_features: @sticky_features, + __featurevisor_child_sticky_variables: @sticky_variables + } end - # Get all evaluations - # @param context [Hash] Context - # @param feature_keys [Array] Feature keys to evaluate - # @param options [Hash] Override options - # @return [Hash] All evaluations - def get_all_evaluations(context = {}, feature_keys = [], options = {}) - @parent.get_all_evaluations( - { - **@context, - **context - }, - feature_keys, - { - **options, - __featurevisor_child_sticky: @sticky - } - ) + def delegate_variable(method, first, second, third, fourth) + if second.nil? || second.is_a?(Hash) + @parent.public_send(method, first, child_context(second || {}), child_options(third)) + else + @parent.public_send(method, first, second, child_context(third), child_options(fourth)) + end end - - private end end diff --git a/lib/featurevisor/emitter.rb b/lib/featurevisor/emitter.rb index f55a835..3b7854e 100644 --- a/lib/featurevisor/emitter.rb +++ b/lib/featurevisor/emitter.rb @@ -2,7 +2,7 @@ module Featurevisor # Event names for the emitter - EVENT_NAMES = %w[datafile_set context_set sticky_set error].freeze + EVENT_NAMES = %w[datafile_set context_set sticky_features_set sticky_variables_set error].freeze # Event emitter class for handling event subscriptions and triggers class Emitter diff --git a/lib/featurevisor/evaluate.rb b/lib/featurevisor/evaluate.rb index c8ea72f..5e1fad8 100644 --- a/lib/featurevisor/evaluate.rb +++ b/lib/featurevisor/evaluate.rb @@ -19,6 +19,7 @@ module EvaluationReason VARIABLE_DISABLED = "variable_disabled" # feature is disabled, and variable's disabledValue is used VARIABLE_OVERRIDE_VARIATION = "variable_override_variation" # variable overridden from inside a variation VARIABLE_OVERRIDE_RULE = "variable_override_rule" # variable overridden from inside a rule + REQUIRED_FEATURES_UNMET = "required_features_unmet" # Common NO_MATCH = "no_match" # no rules matched @@ -35,6 +36,50 @@ module EvaluationReason # Evaluation module for feature flag evaluation module Evaluate + def self.required_features_are_matched(requirements, datafile, options) + return true if requirements.nil? + + items = requirements.is_a?(Array) ? requirements : [requirements] + clean_options = options.reject do |key, _| + %i[feature_key variable_key default_variation_value default_variable_value].include?(key) + end + items.all? do |required| + if required.is_a?(String) + key = required + enabled = true + variation = nil + elsif required.key?(:feature) + key = required[:feature] + enabled = required.key?(:enabled) ? required[:enabled] : true + variation = required[:variation] + else + key = required[:key] + enabled = true + variation = required[:variation] + end + + flag = evaluate_with_modules(clean_options.merge(type: "flag", feature_key: key, datafile: datafile)) + next false unless (flag[:enabled] == true) == enabled + next true if variation.nil? + + evaluated_variation = evaluate_with_modules(clean_options.merge(type: "variation", feature_key: key, datafile: datafile)) + value = evaluated_variation.key?(:variation_value) ? evaluated_variation[:variation_value] : evaluated_variation.dig(:variation, :value) + value == variation + end + end + + def self.variable_override_matches?(override, datafile, context, options) + return false unless required_features_are_matched(override[:requiredFeatures], datafile, options) + + conditions_match = !override[:conditions] || datafile.all_conditions_are_matched( + datafile.parse_conditions_if_stringified(override[:conditions]), context + ) + segments_match = !override[:segments] || datafile.all_segments_are_matched( + datafile.parse_segments_if_stringified(override[:segments]), context + ) + conditions_match && segments_match && + (override.key?(:conditions) || override.key?(:segments) || override.key?(:requiredFeatures)) + end # Evaluate with modules # @param options [Hash] Evaluation options @@ -53,18 +98,18 @@ def self.evaluate_with_modules(options) evaluation = evaluate(result_options) # Default: variation - if options.key?(:default_variation_value) && + if result_options.key?(:default_variation_value) && evaluation[:type] == "variation" && !evaluation.key?(:variation_value) && !evaluation.key?(:variation) - evaluation[:variation_value] = options[:default_variation_value] + evaluation[:variation_value] = result_options[:default_variation_value] end # Default: variable - if options.key?(:default_variable_value) && + if result_options.key?(:default_variable_value) && evaluation[:type] == "variable" && !evaluation.key?(:variable_value) - evaluation[:variable_value] = options[:default_variable_value] + evaluation[:variable_value] = result_options[:default_variable_value] end # Run after modules @@ -358,39 +403,9 @@ def self.evaluate(options) end # Required - if type == "flag" && feature[:required] && feature[:required].length > 0 - required_features_are_enabled = feature[:required].all? do |required| - required_key = nil - required_variation = nil - - if required.is_a?(String) - required_key = required - else - required_key = required[:key] - required_variation = required[:variation] - end - - required_evaluation = evaluate(options.merge(type: "flag", feature_key: required_key)) - required_is_enabled = required_evaluation[:enabled] - - next false unless required_is_enabled - - if required_variation - required_variation_evaluation = evaluate(options.merge(type: "variation", feature_key: required_key)) - - required_variation_value = nil - - if has_key?(required_variation_evaluation, :variation_value) - required_variation_value = fetch_with_symbol_key(required_variation_evaluation, :variation_value) - elsif required_variation_evaluation[:variation] - required_variation_value = required_variation_evaluation[:variation][:value] - end - - next required_variation_value == required_variation - end - - true - end + required_features = feature[:requiredFeatures] || feature[:required] + if type == "flag" && required_features && !Array(required_features).empty? + required_features_are_enabled = required_features_are_matched(required_features, datafile, options) unless required_features_are_enabled evaluation = { @@ -398,6 +413,7 @@ def self.evaluate(options) feature_key: feature_key, reason: Featurevisor::EvaluationReason::REQUIRED, required: feature[:required], + required_features: feature[:requiredFeatures], enabled: required_features_are_enabled } @@ -601,17 +617,7 @@ def self.evaluate(options) has_key?(matched_traffic[:variableOverrides], variable_key) overrides = fetch_with_symbol_key(matched_traffic[:variableOverrides], variable_key) - override_index = overrides.find_index do |o| - if o[:conditions] - conditions = o[:conditions].is_a?(String) && o[:conditions] != "*" ? JSON.parse(o[:conditions]) : o[:conditions] - datafile.all_conditions_are_matched(conditions, context) - elsif o[:segments] - segments = datafile.parse_segments_if_stringified(o[:segments]) - datafile.all_segments_are_matched(segments, context) - else - false - end - end + override_index = overrides.find_index { |o| variable_override_matches?(o, datafile, context, options) } unless override_index.nil? override = overrides[override_index] @@ -627,7 +633,8 @@ def self.evaluate(options) variable_key: variable_key, variable_schema: variable_schema, variable_value: override[:value], - variable_override_index: override_index + variable_override_index: override_index, + variable_override_key: override[:key] } diagnostics.debug("variable override from rule", evaluation) @@ -675,17 +682,7 @@ def self.evaluate(options) if variation && variation[:variableOverrides] && has_key?(variation[:variableOverrides], variable_key) overrides = fetch_with_symbol_key(variation[:variableOverrides], variable_key) - override_index = overrides.find_index do |o| - if o[:conditions] - conditions = o[:conditions].is_a?(String) && o[:conditions] != "*" ? JSON.parse(o[:conditions]) : o[:conditions] - datafile.all_conditions_are_matched(conditions, context) - elsif o[:segments] - segments = datafile.parse_segments_if_stringified(o[:segments]) - datafile.all_segments_are_matched(segments, context) - else - false - end - end + override_index = overrides.find_index { |o| variable_override_matches?(o, datafile, context, options) } unless override_index.nil? override = overrides[override_index] @@ -700,7 +697,8 @@ def self.evaluate(options) variable_key: variable_key, variable_schema: variable_schema, variable_value: override[:value], - variable_override_index: override_index + variable_override_index: override_index, + variable_override_key: override[:key] } diagnostics.debug("variable override from variation", evaluation) diff --git a/lib/featurevisor/evaluation_data_provider.rb b/lib/featurevisor/evaluation_data_provider.rb index e0fcb54..7074bfc 100644 --- a/lib/featurevisor/evaluation_data_provider.rb +++ b/lib/featurevisor/evaluation_data_provider.rb @@ -5,7 +5,7 @@ module Featurevisor # Private datafile and matching adapter used by the evaluator. class InstanceEvaluationDataProvider - attr_reader :schema_version, :revision, :featurevisor_version, :segments, :features, :diagnostics, :regex_cache + attr_reader :schema_version, :revision, :featurevisor_version, :segments, :features, :variables, :diagnostics, :regex_cache # Initialize a new evaluation data provider. # @param options [Hash] Options hash containing datafile and diagnostics @@ -20,6 +20,7 @@ def initialize(options) @featurevisor_version = datafile[:featurevisorVersion] @segments = (datafile[:segments] || {}).transform_keys(&:to_sym) @features = (datafile[:features] || {}).transform_keys(&:to_sym) + @variables = (datafile[:variables] || {}).transform_keys(&:to_sym) # Transform nested structures to use symbol keys @features.each do |_key, feature| @@ -72,7 +73,8 @@ def get_datafile revision: @revision, featurevisorVersion: @featurevisor_version, segments: @segments, - features: @features + features: @features, + variables: @variables }.compact end @@ -88,6 +90,10 @@ def get_segment(segment_key) segment end + def get_segment_keys + @segments.keys + end + # Get all feature keys # @return [Array] Array of feature keys def get_feature_keys @@ -104,7 +110,9 @@ def get_feature(feature_key) # Get variable keys for a feature # @param feature_key [String] Feature key # @return [Array] Array of variable keys - def get_variable_keys(feature_key) + def get_variable_keys(feature_key = nil) + return @variables.keys if feature_key.nil? + feature = get_feature(feature_key) return [] unless feature @@ -112,6 +120,10 @@ def get_variable_keys(feature_key) (feature[:variablesSchema] || {}).keys end + def get_global_variable(variable_key) + @variables[variable_key.to_sym] || @variables[variable_key] + end + # Check if a feature has variations # @param feature_key [String] Feature key # @return [Boolean] True if feature has variations diff --git a/lib/featurevisor/events.rb b/lib/featurevisor/events.rb index c3b3258..1a4b9a1 100644 --- a/lib/featurevisor/events.rb +++ b/lib/featurevisor/events.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "json" + module Featurevisor # Events module for generating event parameters module Events @@ -8,7 +10,7 @@ module Events # @param new_sticky [Hash] New sticky features # @param replace [Boolean] Whether features were replaced # @return [Hash] Event parameters - def self.get_params_for_sticky_set_event(previous_sticky = {}, new_sticky = {}, replace = false) + def self.get_params_for_sticky_features_set_event(previous_sticky = {}, new_sticky = {}, replace = false) keys_before = previous_sticky.keys keys_after = new_sticky.keys @@ -20,6 +22,13 @@ def self.get_params_for_sticky_set_event(previous_sticky = {}, new_sticky = {}, } end + def self.get_params_for_sticky_variables_set_event(previous_sticky = {}, new_sticky = {}, replace = false) + { + variables: (previous_sticky.keys + new_sticky.keys).uniq, + replaced: replace + } + end + # Get parameters for datafile set event # @param previous_reader [InstanceEvaluationDataProvider] Previous datafile # @param new_reader [InstanceEvaluationDataProvider] New datafile @@ -30,6 +39,8 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace new_revision = new_reader.get_revision new_feature_keys = new_reader.get_feature_keys + previous_variable_keys = previous_reader.get_variable_keys + new_variable_keys = new_reader.get_variable_keys # results removed_features = [] @@ -48,7 +59,8 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace previous_feature = previous_reader.get_feature(previous_feature_key) new_feature = new_reader.get_feature(previous_feature_key) - if previous_feature && new_feature && previous_feature[:hash] != new_feature[:hash] + if previous_feature && new_feature && + (previous_feature[:hash].nil? || new_feature[:hash].nil? || previous_feature[:hash] != new_feature[:hash]) # feature was changed in new datafile changed_features << previous_feature_key end @@ -64,14 +76,118 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace # combine all affected feature keys all_affected_features = (removed_features + changed_features + added_features).uniq + all_affected_variables = (previous_variable_keys + new_variable_keys).uniq.select do |key| + previous = previous_reader.get_global_variable(key) + current = new_reader.get_global_variable(key) + previous.nil? || current.nil? || previous[:hash].nil? || current[:hash].nil? || previous[:hash] != current[:hash] + end + + + changed_segments = (previous_reader.get_segment_keys + new_reader.get_segment_keys).uniq.select do |key| + previous_reader.get_segment(key) != new_reader.get_segment(key) + end.map(&:to_s) + feature_keys = (previous_feature_keys + new_feature_keys).uniq + loop do + before = all_affected_features.length + feature_keys.each do |key| + next if all_affected_features.include?(key) + + candidates = [previous_reader.get_feature(key), new_reader.get_feature(key)].compact + all_affected_features << key if candidates.any? do |feature| + dependency = feature_dependencies(feature) + !(dependency[:segments].map(&:to_s) & changed_segments).empty? || + !(dependency[:features].map(&:to_s) & all_affected_features.map(&:to_s)).empty? + end + end + break if before == all_affected_features.length + end + + (previous_variable_keys + new_variable_keys).uniq.each do |key| + next if all_affected_variables.include?(key) + + candidates = [previous_reader.get_global_variable(key), new_reader.get_global_variable(key)].compact + all_affected_variables << key if candidates.any? do |variable| + dependency = global_variable_dependencies(variable) + !(dependency[:segments].map(&:to_s) & changed_segments).empty? || + !(dependency[:features].map(&:to_s) & all_affected_features.map(&:to_s)).empty? + end + end { revision: new_revision, previousRevision: previous_revision, revisionChanged: previous_revision != new_revision, features: all_affected_features, + variables: all_affected_variables, replaced: replace } end + + def self.required_feature_keys(values) + items = values.is_a?(Array) ? values : [values].compact + items.filter_map do |required| + required.is_a?(String) ? required : (required[:feature] || required[:key]) + end + end + private_class_method :required_feature_keys + + def self.segment_keys(value) + return [] if value.nil? || value == "*" + if value.is_a?(String) + if value.start_with?("{", "[") + begin + return segment_keys(JSON.parse(value, symbolize_names: true)) + rescue JSON::ParserError + return [] + end + end + return [value] + end + return value.flat_map { |item| segment_keys(item) }.uniq if value.is_a?(Array) + return [] unless value.is_a?(Hash) + + %i[and or not].flat_map { |key| segment_keys(value[key] || value[key.to_s]) }.uniq + end + private_class_method :segment_keys + + def self.override_dependencies(groups) + overrides = groups.is_a?(Hash) ? groups.values.flatten : [] + { + segments: overrides.flat_map { |override| segment_keys(override[:segments]) }.uniq, + features: overrides.flat_map { |override| required_feature_keys(override[:requiredFeatures]) }.uniq + } + end + private_class_method :override_dependencies + + def self.feature_dependencies(feature) + requirements = feature.key?(:requiredFeatures) ? feature[:requiredFeatures] : feature[:required] + segments = [] + features = required_feature_keys(requirements) + Array(feature[:traffic]).each do |traffic| + segments.concat(segment_keys(traffic[:segments])) + nested = override_dependencies(traffic[:variableOverrides]) + segments.concat(nested[:segments]) + features.concat(nested[:features]) + end + Array(feature[:force]).each { |force| segments.concat(segment_keys(force[:segments])) } + Array(feature[:variations]).each do |variation| + nested = override_dependencies(variation[:variableOverrides]) + segments.concat(nested[:segments]) + features.concat(nested[:features]) + end + { segments: segments.uniq, features: features.uniq } + end + private_class_method :feature_dependencies + + def self.global_variable_dependencies(variable) + segments = [] + features = required_feature_keys(variable[:requiredFeatures]) + Array(variable[:overrides]).each do |override| + segments.concat(segment_keys(override[:segments])) + features.concat(required_feature_keys(override[:requiredFeatures])) + end + { segments: segments.uniq, features: features.uniq } + end + private_class_method :global_variable_dependencies end end diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index bbb3559..6425e8a 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -11,7 +11,8 @@ class Instance schemaVersion: "2", revision: "unknown", segments: {}, - features: {} + features: {}, + variables: {} }.freeze # Initialize a new Featurevisor instance @@ -19,7 +20,7 @@ class Instance # @option options [Hash, String] :datafile Datafile content or JSON string # @option options [Hash] :context Initial context # @option options [String] :log_level Log level - # @option options [Hash] :sticky Sticky features + # @option options [Hash] :sticky_features Sticky features # @option options [Array] :modules Array of modules # @option options [Proc] :on_diagnostic Diagnostic handler def initialize(options = {}) @@ -31,7 +32,8 @@ def initialize(options = {}) ) @on_diagnostic = options[:on_diagnostic] || options[:onDiagnostic] @emitter = Featurevisor::Emitter.new - @sticky = options[:sticky] || {} + @sticky_features = options[:sticky_features] || options[:stickyFeatures] || {} + @sticky_variables = options[:sticky_variables] || options[:stickyVariables] || {} @closed = false @module_diagnostic_subscriptions = [] @@ -108,7 +110,8 @@ def set_datafile(datafile, replace = false) end unless parsed_datafile.is_a?(Hash) && parsed_datafile[:schemaVersion].is_a?(String) && parsed_datafile[:revision].is_a?(String) && parsed_datafile[:segments].is_a?(Hash) && - parsed_datafile[:features].is_a?(Hash) + parsed_datafile[:features].is_a?(Hash) && + (!parsed_datafile.key?(:variables) || parsed_datafile[:variables].is_a?(Hash)) raise ArgumentError, "Invalid datafile" end next_datafile = replace ? parsed_datafile : merge_datafiles(@datafile.get_datafile, parsed_datafile) @@ -140,27 +143,35 @@ def set_datafile(datafile, replace = false) # Set sticky features # @param sticky [Hash] Sticky features # @param replace [Boolean] Whether to replace existing sticky features - def set_sticky(sticky, replace = false) - previous_sticky_features = @sticky || {} + def set_sticky_features(sticky, replace = false) + previous_sticky_features = @sticky_features || {} if replace - @sticky = sticky + @sticky_features = sticky else - @sticky = { - **@sticky, + @sticky_features = { + **@sticky_features, **sticky } end - params = Featurevisor::Events.get_params_for_sticky_set_event(previous_sticky_features, @sticky, replace) + params = Featurevisor::Events.get_params_for_sticky_features_set_event(previous_sticky_features, @sticky_features, replace) report_diagnostic( level: "info", - code: "sticky_set", + code: "sticky_features_set", message: "Sticky features set", details: params ) - @emitter.trigger("sticky_set", params) + @emitter.trigger("sticky_features_set", params) + end + + def set_sticky_variables(sticky, replace = false) + previous = @sticky_variables || {} + @sticky_variables = replace ? sticky : { **@sticky_variables, **sticky } + params = Featurevisor::Events.get_params_for_sticky_variables_set_event(previous, @sticky_variables, replace) + report_diagnostic(level: "info", code: "sticky_variables_set", message: "Sticky variables set", details: params) + @emitter.trigger("sticky_variables_set", params) end # Get the revision @@ -181,7 +192,7 @@ def get_feature_keys @datafile.get_feature_keys end - def get_variable_keys(feature_key) + def get_variable_keys(feature_key = nil) @datafile.get_variable_keys(feature_key) end @@ -275,7 +286,8 @@ def spawn(context = {}, options = {}) Featurevisor::ChildInstance.new( parent: self, context: get_context(context), - sticky: options[:sticky] + sticky_features: options[:sticky_features] || options[:stickyFeatures], + sticky_variables: options[:sticky_variables] || options[:stickyVariables] ) end @@ -350,12 +362,17 @@ def get_variation(feature_key, context = {}, options = {}) # @param context [Hash] Context # @param options [Hash] Override options # @return [Hash] Evaluation result - def evaluate_variable(feature_key, variable_key, context = {}, options = {}) + def evaluate_variable(feature_or_variable_key, variable_key_or_context = nil, context_or_options = {}, options = {}) + if variable_key_or_context.nil? || variable_key_or_context.is_a?(Hash) + context = variable_key_or_context || {} + return evaluate_variable_without_feature(feature_or_variable_key, context, context_or_options) + end + Featurevisor::Evaluate.evaluate_with_modules( - get_evaluation_dependencies(context, options).merge( + get_evaluation_dependencies(context_or_options, options).merge( type: "variable", - feature_key: feature_key, - variable_key: variable_key + feature_key: feature_or_variable_key, + variable_key: variable_key_or_context ) ) end @@ -366,13 +383,13 @@ def evaluate_variable(feature_key, variable_key, context = {}, options = {}) # @param context [Hash] Context # @param options [Hash] Override options # @return [Object, nil] Variable value or nil - def get_variable(feature_key, variable_key, context = {}, options = {}) + def get_variable(feature_or_variable_key, variable_key_or_context = nil, context_or_options = {}, options = {}) begin - evaluation = evaluate_variable(feature_key, variable_key, context, options) + evaluation = evaluate_variable(feature_or_variable_key, variable_key_or_context, context_or_options, options) if evaluation.key?(:variable_value) - if evaluation[:variable_schema] && - evaluation[:variable_schema][:type] == "json" && + variable_type = evaluation.dig(:variable_schema, :type) || evaluation.dig(:variable, :type) + if variable_type == "json" && evaluation[:variable_value].is_a?(String) JSON.parse(evaluation[:variable_value], symbolize_names: true) else @@ -382,7 +399,7 @@ def get_variable(feature_key, variable_key, context = {}, options = {}) nil end rescue => e - report_diagnostic(level: "error", code: "evaluation_error", message: "getVariable failed", originalError: e, details: { featureKey: feature_key, variableKey: variable_key }) + report_diagnostic(level: "error", code: "evaluation_error", message: "getVariable failed", originalError: e, details: { variableKey: variable_key_or_context || feature_or_variable_key }) nil end end @@ -393,8 +410,8 @@ def get_variable(feature_key, variable_key, context = {}, options = {}) # @param context [Hash] Context # @param options [Hash] Override options # @return [Boolean, nil] Boolean value or nil - def get_variable_boolean(feature_key, variable_key, context = {}, options = {}) - variable_value = get_variable(feature_key, variable_key, context, options) + def get_variable_boolean(*args) + variable_value = get_variable(*args) get_value_by_type(variable_value, "boolean") end @@ -404,8 +421,8 @@ def get_variable_boolean(feature_key, variable_key, context = {}, options = {}) # @param context [Hash] Context # @param options [Hash] Override options # @return [String, nil] String value or nil - def get_variable_string(feature_key, variable_key, context = {}, options = {}) - variable_value = get_variable(feature_key, variable_key, context, options) + def get_variable_string(*args) + variable_value = get_variable(*args) get_value_by_type(variable_value, "string") end @@ -415,8 +432,8 @@ def get_variable_string(feature_key, variable_key, context = {}, options = {}) # @param context [Hash] Context # @param options [Hash] Override options # @return [Integer, nil] Integer value or nil - def get_variable_integer(feature_key, variable_key, context = {}, options = {}) - variable_value = get_variable(feature_key, variable_key, context, options) + def get_variable_integer(*args) + variable_value = get_variable(*args) get_value_by_type(variable_value, "integer") end @@ -426,8 +443,8 @@ def get_variable_integer(feature_key, variable_key, context = {}, options = {}) # @param context [Hash] Context # @param options [Hash] Override options # @return [Float, nil] Float value or nil - def get_variable_double(feature_key, variable_key, context = {}, options = {}) - variable_value = get_variable(feature_key, variable_key, context, options) + def get_variable_double(*args) + variable_value = get_variable(*args) get_value_by_type(variable_value, "double") end @@ -437,8 +454,8 @@ def get_variable_double(feature_key, variable_key, context = {}, options = {}) # @param context [Hash] Context # @param options [Hash] Override options # @return [Array, nil] Array value or nil - def get_variable_array(feature_key, variable_key, context = {}, options = {}) - variable_value = get_variable(feature_key, variable_key, context, options) + def get_variable_array(*args) + variable_value = get_variable(*args) get_value_by_type(variable_value, "array") end @@ -448,8 +465,8 @@ def get_variable_array(feature_key, variable_key, context = {}, options = {}) # @param context [Hash] Context # @param options [Hash] Override options # @return [Hash, nil] Object value or nil - def get_variable_object(feature_key, variable_key, context = {}, options = {}) - variable_value = get_variable(feature_key, variable_key, context, options) + def get_variable_object(*args) + variable_value = get_variable(*args) get_value_by_type(variable_value, "object") end @@ -459,8 +476,8 @@ def get_variable_object(feature_key, variable_key, context = {}, options = {}) # @param context [Hash] Context # @param options [Hash] Override options # @return [Object, nil] JSON value or nil - def get_variable_json(feature_key, variable_key, context = {}, options = {}) - variable_value = get_variable(feature_key, variable_key, context, options) + def get_variable_json(*args) + variable_value = get_variable(*args) get_value_by_type(variable_value, "json") end @@ -469,7 +486,7 @@ def get_variable_json(feature_key, variable_key, context = {}, options = {}) # @param feature_keys [Array] Feature keys to evaluate # @param options [Hash] Override options # @return [Hash] All evaluations - def get_all_evaluations(context = {}, feature_keys = [], options = {}) + def get_feature_evaluations(context = {}, feature_keys = [], options = {}) result = {} keys = feature_keys.size > 0 ? feature_keys : @datafile.get_feature_keys @@ -510,8 +527,100 @@ def get_all_evaluations(context = {}, feature_keys = [], options = {}) result end + def get_variable_evaluations(context = {}, variable_keys = [], options = {}) + keys = variable_keys.empty? ? @datafile.get_variable_keys : variable_keys + keys.to_h { |key| [key, get_variable(key.to_s, context, options)] } + end + private + def evaluate_variable_without_feature(variable_key, context = {}, options = {}) + evaluation_options = { + type: "variable", + variable_key: variable_key.to_s, + context: get_context(context) + } + evaluation_options[:default_variable_value] = options[:default_variable_value] if options.key?(:default_variable_value) + begin + evaluation_options = @modules_manager.run_before_evaluation_modules(evaluation_options) + resolved_key = evaluation_options[:variable_key] + variable = @datafile.get_global_variable(resolved_key) + sticky = options[:__featurevisor_child_sticky_variables] || @sticky_variables + evaluation = { type: "variable", variable_key: resolved_key, reason: Featurevisor::EvaluationReason::VARIABLE_NOT_FOUND } + + if sticky.key?(resolved_key) || sticky.key?(resolved_key.to_sym) + sticky_value = sticky.key?(resolved_key) ? sticky[resolved_key] : sticky[resolved_key.to_sym] + evaluation.merge!(reason: Featurevisor::EvaluationReason::STICKY, variable: variable, + variable_value: sticky_value) + elsif variable + unless required_features_are_matched(variable[:requiredFeatures], evaluation_options[:context], options) + value_key = variable[:useDefaultWhenDisabled] ? :defaultValue : :disabledValue + evaluation.merge!(reason: Featurevisor::EvaluationReason::REQUIRED_FEATURES_UNMET, + variable: variable) + evaluation[:variable_value] = variable[value_key] if variable.key?(value_key) + else + (variable[:overrides] || []).each_with_index do |override, index| + next unless required_features_are_matched(override[:requiredFeatures], evaluation_options[:context], options) + conditions_match = !override[:conditions] || @datafile.all_conditions_are_matched( + @datafile.parse_conditions_if_stringified(override[:conditions]), evaluation_options[:context] + ) + segments_match = !override[:segments] || @datafile.all_segments_are_matched( + @datafile.parse_segments_if_stringified(override[:segments]), evaluation_options[:context] + ) + next unless conditions_match && segments_match + + evaluation.merge!(reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_RULE, + variable: variable, + variable_override_index: index, variable_override_key: override[:key], + variable_override_path: override[:keyPath]) + evaluation[:variable_value] = override[:value] if override.key?(:value) + break + end + if evaluation[:reason] == Featurevisor::EvaluationReason::VARIABLE_NOT_FOUND + evaluation.merge!(reason: Featurevisor::EvaluationReason::VARIABLE_DEFAULT, + variable: variable) + evaluation[:variable_value] = variable[:defaultValue] if variable.key?(:defaultValue) + end + end + report_diagnostic(level: "warn", code: "variable_deprecated", message: "Variable \"#{resolved_key}\" is deprecated", + details: { variableKey: resolved_key, evaluation: evaluation }) if variable[:deprecated] + end + + if !evaluation.key?(:variable_value) && evaluation_options.key?(:default_variable_value) + evaluation[:variable_value] = evaluation_options[:default_variable_value] + end + evaluation = @modules_manager.run_global_after_modules(evaluation, evaluation_options) + report_diagnostic(level: "debug", code: evaluation[:reason], message: "Global variable evaluated", details: evaluation) + evaluation + rescue => e + evaluation = { type: "variable", variable_key: evaluation_options[:variable_key], + reason: Featurevisor::EvaluationReason::ERROR, error: e } + report_diagnostic(level: "error", code: "evaluation_error", message: "Global variable evaluation failed", + originalError: e, details: evaluation) + evaluation + end + end + + def required_features_are_matched(requirements, context, options) + return true if requirements.nil? + + items = requirements.is_a?(Array) ? requirements : [requirements] + clean_options = options.reject { |key, _| %i[default_variation_value default_variable_value].include?(key) } + items.all? do |required| + if required.is_a?(String) + key = required + expected_enabled = true + expected_variation = nil + else + key = required[:feature] + expected_enabled = required.key?(:enabled) ? required[:enabled] : true + expected_variation = required[:variation] + end + next false unless is_enabled(key, context, clean_options) == expected_enabled + expected_variation.nil? || get_variation(key, context, clean_options) == expected_variation + end + end + # Get evaluation dependencies # @param context [Hash] Context # @param options [Hash] Override options @@ -522,7 +631,8 @@ def get_evaluation_dependencies(context, options = {}) diagnostics: @diagnostics, modules_manager: @modules_manager, datafile: @datafile, - sticky: options[:__featurevisor_child_sticky] || @sticky, + sticky: options[:__featurevisor_child_sticky_features] || @sticky_features, + sticky_variables: options[:__featurevisor_child_sticky_variables] || @sticky_variables, }.tap do |dependencies| dependencies[:default_variation_value] = options[:default_variation_value] if options.key?(:default_variation_value) dependencies[:default_variable_value] = options[:default_variable_value] if options.key?(:default_variable_value) @@ -573,6 +683,10 @@ def merge_datafiles(previous, incoming) features: { **(previous[:features] || {}), **(incoming[:features] || {}) + }, + variables: { + **(previous[:variables] || {}), + **(incoming[:variables] || {}) } }.compact end diff --git a/lib/featurevisor/modules.rb b/lib/featurevisor/modules.rb index 650eceb..260b63a 100644 --- a/lib/featurevisor/modules.rb +++ b/lib/featurevisor/modules.rb @@ -13,9 +13,11 @@ def initialize(options = {}) @name = options[:name] @setup = options[:setup] @before = options[:before] + @before_evaluation = options[:before_evaluation] || options[:beforeEvaluation] @bucket_key = options[:bucket_key] @bucket_value = options[:bucket_value] @after = options[:after] + @after_evaluation = options[:after_evaluation] || options[:afterEvaluation] @close = options[:close] end @@ -29,6 +31,12 @@ def call_before(options) @before.call(options) end + def call_before_evaluation(options) + return options unless @before_evaluation + + @before_evaluation.call(options) + end + def call_bucket_key(options) return options[:bucket_key] unless @bucket_key @@ -47,6 +55,12 @@ def call_after(evaluation, options) @after.call(evaluation, options) end + def call_after_evaluation(evaluation, options) + return evaluation unless @after_evaluation + + @after_evaluation.call(evaluation, options) + end + def call_close @close.call if @close end @@ -124,6 +138,43 @@ def get_all end def run_before_modules(options) + result = @modules.reduce(options) do |current, mod| + mod.call_before(current) + end + @modules.reduce(result) do |current, mod| + mod.call_before_evaluation(current) + end + end + + def run_before_evaluation_modules(options) + @modules.reduce(options) do |result, mod| + mod.call_before_evaluation(result) + end + end + + def run_after_evaluation_modules(evaluation, options) + @modules.reduce(evaluation) do |result, mod| + mod.call_after_evaluation(result, options) + end + end + + def run_after_modules(evaluation, options) + result = @modules.reduce(evaluation) do |current, mod| + mod.call_after_evaluation(current, options) + end + @modules.reduce(result) do |current, mod| + mod.call_after(current, options) + end + end + + # Deprecated feature-only callbacks are intentionally excluded here. + def run_global_after_modules(evaluation, options) + @modules.reduce(evaluation) do |result, mod| + mod.call_after_evaluation(result, options) + end + end + + def run_legacy_before_modules(options) @modules.reduce(options) do |result, mod| mod.call_before(result) end @@ -145,12 +196,6 @@ def run_bucket_value_modules(options) bucket_value end - def run_after_modules(evaluation, options) - @modules.reduce(evaluation) do |result, mod| - mod.call_after(result, options) - end - end - def close_all @modules.each do |mod| @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions diff --git a/lib/featurevisor/openfeature_provider.rb b/lib/featurevisor/openfeature_provider.rb index 22bccbe..047e6e5 100644 --- a/lib/featurevisor/openfeature_provider.rb +++ b/lib/featurevisor/openfeature_provider.rb @@ -11,13 +11,15 @@ class OpenFeatureProvider attr_reader :metadata, :featurevisor - def initialize(options = {}, featurevisor: nil, targeting_key_field: "userId", key_separator: ":", variation_key: "variation", on_track: nil, **featurevisor_options) + def initialize(options = {}, featurevisor: nil, targeting_key_field: "userId", key_separator: ":", variation_key: "variation", global_variable_prefix: "variable", on_track: nil, **featurevisor_options) raise ArgumentError, "options must be a Hash" unless options.is_a?(Hash) @metadata = Provider::ProviderMetadata.new(name: "Featurevisor").freeze @targeting_key_field = targeting_key_field.empty? ? "userId" : targeting_key_field @key_separator = key_separator.empty? ? ":" : key_separator @variation_key = variation_key.empty? ? "variation" : variation_key + @global_variable_prefix = global_variable_prefix.empty? ? "variable" : global_variable_prefix + raise ArgumentError, "global_variable_prefix cannot contain key_separator" if @global_variable_prefix.include?(@key_separator) @on_track = on_track @datafile_error = nil @shutdown = false @@ -86,7 +88,10 @@ def resolve(flag_key, default_value, evaluation_context, expected_type) targeting_key = evaluation_context&.targeting_key context[@targeting_key_field] = targeting_key if targeting_key && !targeting_key.empty? - if selector.nil? || selector.empty? + if feature_key == @global_variable_prefix && selector && !selector.empty? + evaluation = featurevisor.evaluate_variable(selector, context) + value = evaluation[:variable_value] + elsif selector.nil? || selector.empty? return type_mismatch(flag_key, default_value, expected_type) unless expected_type == :boolean evaluation = featurevisor.evaluate_flag(feature_key, context) value = evaluation[:enabled] @@ -96,7 +101,8 @@ def resolve(flag_key, default_value, evaluation_context, expected_type) else evaluation = featurevisor.evaluate_variable(feature_key, selector, context) value = evaluation[:variable_value] - if evaluation.dig(:variable_schema, :type) == "json" && value.is_a?(String) + variable_type = evaluation.dig(:variable_schema, :type) || evaluation.dig(:variable, :type) + if variable_type == "json" && value.is_a?(String) begin value = JSON.parse(value) rescue JSON::ParserError @@ -127,10 +133,10 @@ def split_key(key) def metadata_for(evaluation) metadata = { - "featureKey" => evaluation[:feature_key], "featurevisorReason" => evaluation[:reason], "schemaVersion" => featurevisor.get_schema_version } + metadata["featureKey"] = evaluation[:feature_key] unless evaluation[:feature_key].nil? metadata["revision"] = featurevisor.get_revision if featurevisor.get_revision { variable_key: "variableKey", @@ -138,7 +144,9 @@ def metadata_for(evaluation) bucket_key: "bucketKey", bucket_value: "bucketValue", force_index: "forceIndex", - variable_override_index: "variableOverrideIndex" + variable_override_index: "variableOverrideIndex", + variable_override_key: "variableOverrideKey", + variable_override_path: "variableOverridePath" }.each do |key, metadata_key| metadata[metadata_key] = evaluation[key] unless evaluation[key].nil? end @@ -149,7 +157,7 @@ def reason(value) return Provider::Reason::ERROR if %w[feature_not_found variable_not_found no_variations error].include?(value) return Provider::Reason::TARGETING_MATCH if %w[required forced sticky rule variable_override_variation variable_override_rule].include?(value) return Provider::Reason::SPLIT if value == "allocated" - return Provider::Reason::DISABLED if %w[disabled variation_disabled variable_disabled].include?(value) + return Provider::Reason::DISABLED if %w[disabled variation_disabled variable_disabled required_features_unmet].include?(value) Provider::Reason::DEFAULT end @@ -162,7 +170,10 @@ def error_code(value) def error_message(evaluation) return evaluation[:error].message if evaluation[:error].respond_to?(:message) return %(Feature "#{evaluation[:feature_key]}" was not found) if evaluation[:reason] == "feature_not_found" - return %(Variable "#{evaluation[:variable_key]}" was not found for feature "#{evaluation[:feature_key]}") if evaluation[:reason] == "variable_not_found" + if evaluation[:reason] == "variable_not_found" + return %(Variable "#{evaluation[:variable_key]}" was not found) unless evaluation[:feature_key] + return %(Variable "#{evaluation[:variable_key]}" was not found for feature "#{evaluation[:feature_key]}") + end return %(Feature "#{evaluation[:feature_key]}" has no variations) if evaluation[:reason] == "no_variations" "Featurevisor evaluation failed" end diff --git a/lib/featurevisor/version.rb b/lib/featurevisor/version.rb index 2bd30d7..cf1748a 100644 --- a/lib/featurevisor/version.rb +++ b/lib/featurevisor/version.rb @@ -1,3 +1,3 @@ module Featurevisor - VERSION = "2.0.0" + VERSION = "3.0.0" end diff --git a/spec/child_instance_spec.rb b/spec/child_instance_spec.rb index d02bed1..56ccbe6 100644 --- a/spec/child_instance_spec.rb +++ b/spec/child_instance_spec.rb @@ -254,7 +254,7 @@ unsubscribe_context.call expect(child_f.is_enabled("newFeature")).to be false - child_f.set_sticky({ + child_f.set_sticky_features({ newFeature: { enabled: true } @@ -262,7 +262,7 @@ expect(child_f.is_enabled("newFeature")).to be true expect(child_f.evaluate_flag("newFeature")[:reason]).to eq("sticky") - all_evaluations = child_f.get_all_evaluations + all_evaluations = child_f.get_feature_evaluations expect(all_evaluations.keys).to eq([:test, :anotherTest]) child_f.close diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index 0a553d2..55094d1 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -32,7 +32,7 @@ end it "shows benchmark environment requirement note" do - expect { FeaturevisorCLI.show_help }.to output(/Note: benchmark command requires --environment and --feature options/).to_stdout + expect { FeaturevisorCLI.show_help }.to output(/Note: benchmark requires --environment and either --feature or --variable/).to_stdout end end end diff --git a/spec/conditions_spec.rb b/spec/conditions_spec.rb index 5eaf9dc..59fc4ad 100644 --- a/spec/conditions_spec.rb +++ b/spec/conditions_spec.rb @@ -461,7 +461,7 @@ end end - describe "logical composition via datafile reader" do + describe "logical composition via the evaluation data provider" do it "should match with multiple conditions inside NOT" do conditions = [ { diff --git a/spec/conformance_spec.rb b/spec/conformance_spec.rb index ae9d93e..f819cb6 100644 --- a/spec/conformance_spec.rb +++ b/spec/conformance_spec.rb @@ -6,7 +6,7 @@ RSpec.describe "Featurevisor v3 conformance" do it "uses the shared inclusive allocation contract" do fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) - expect(fixture[:version]).to eq(2) + expect(fixture[:version]).to eq(6) reader = Featurevisor.const_get(:InstanceEvaluationDataProvider).new( datafile: { schemaVersion: "2", revision: "conformance", segments: {}, features: {} }, diagnostics: Featurevisor.const_get(:DiagnosticReporter).new(level: "fatal") @@ -48,7 +48,7 @@ aggregate_case = fixture.dig(:defaults, :aggregateCase) featurevisor = Featurevisor.create_featurevisor(datafile: aggregate_case[:datafile], log_level: "fatal") - evaluated = featurevisor.get_all_evaluations( + evaluated = featurevisor.get_feature_evaluations( {}, [], default_variation_value: aggregate_case[:defaultVariationValue] @@ -56,4 +56,124 @@ expect(evaluated[:enabled]).to eq(aggregate_case.dig(:expected, :enabled)) expect(evaluated[:variation]).to eq(aggregate_case.dig(:expected, :variation)) end + + it "evaluates every shared global variable case" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) + globals = fixture[:globalVariables] + + globals[:cases].each do |test_case| + f = Featurevisor.create_featurevisor( + datafile: globals[:datafile], + sticky_variables: test_case[:stickyVariables] || {}, + log_level: "fatal" + ) + options = {} + options[:default_variable_value] = test_case[:defaultVariableValue] if test_case.key?(:defaultVariableValue) + evaluation = f.evaluate_variable(test_case[:key], test_case[:context] || {}, options) + expect(evaluation[:variable_value]).to eq(test_case[:expectedValue]), test_case[:name] + expect(evaluation[:reason]).to eq(test_case[:expectedReason]), test_case[:name] + expect(evaluation[:variable_override_index]).to eq(test_case[:expectedOverrideIndex]), test_case[:name] + expect(evaluation[:variable_override_key]).to eq(test_case[:expectedOverrideKey]), test_case[:name] + expect(evaluation[:variable_override_path]).to eq(test_case[:expectedOverridePath]), test_case[:name] + end + + boundary = globals[:overloadCase] + f = Featurevisor.create_featurevisor(datafile: globals[:datafile], log_level: "fatal") + expect(f.get_variable(boundary[:sharedKey])).to eq(boundary[:expectedGlobalValue]) + expect(f.get_variable(boundary[:sharedKey], boundary[:featureVariableKey])).to eq(boundary[:expectedFeatureValue]) + expect(f.get_variable_keys.map(&:to_s)).to include(boundary[:sharedKey]) + expect(f.get_variable_evaluations[boundary[:sharedKey].to_sym]).to eq(boundary[:expectedGlobalValue]) + end + + it "preserves explicit null values and module transformed defaults" do + null_datafile = { + schemaVersion: "2", revision: "null-default", segments: {}, features: {}, + variables: { nullable: { key: "nullable", type: "json", defaultValue: nil } } + } + f = Featurevisor.create_featurevisor(datafile: null_datafile, log_level: "fatal") + evaluation = f.evaluate_variable("nullable", {}, default_variable_value: { fallback: true }) + expect(evaluation).to have_key(:variable_value) + expect(evaluation[:variable_value]).to be_nil + + transformed = Featurevisor.create_featurevisor( + datafile: { schemaVersion: "2", revision: "empty", segments: {}, features: {} }, + log_level: "fatal", + modules: [{ + name: "default", + before_evaluation: ->(options) { options.merge(default_variable_value: "from-module") } + }] + ) + expect(transformed.get_variable("missing")).to eq("from-module") + end + + it "supports canonical required features for flags and feature variable overrides" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) + required = fixture[:requiredFeatures] + f = Featurevisor.create_featurevisor(datafile: required[:datafile], log_level: "fatal") + + required[:cases].each do |test_case| + expect(f.is_enabled(test_case[:feature])).to eq(test_case[:expectedEnabled]), test_case[:name] + end + test_case = required[:featureVariableCase] + evaluation = f.evaluate_variable(test_case[:feature], test_case[:variable]) + expect(evaluation[:variable_value]).to eq(test_case[:expectedValue]) + expect(evaluation[:variable_override_key]).to eq(test_case[:expectedOverrideKey]) + end + + it "reports direct and dependency driven variable updates for merge and replacement" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) + globals = fixture[:globalVariables] + update = globals[:datafileUpdateCase] + f = Featurevisor.create_featurevisor(datafile: update[:initial], log_level: "fatal") + events = [] + f.on("datafile_set", ->(event) { events << event }) + f.set_datafile(update[:merge]) + expect(f.get_feature_keys.map(&:to_s).sort).to eq(update.dig(:expectedAfterMerge, :features).sort) + expect(f.get_variable_keys.map(&:to_s).sort).to eq(update.dig(:expectedAfterMerge, :variables).sort) + expect(events.last[:features].map(&:to_s)).to match_array(update.dig(:expectedAfterMerge, :changedFeatures)) + expect(events.last[:variables].map(&:to_s)).to match_array(update.dig(:expectedAfterMerge, :changedVariables)) + + f.set_datafile(update[:replacement], true) + expect(f.get_feature_keys.map(&:to_s).sort).to eq(update.dig(:expectedAfterReplacement, :features).sort) + expect(f.get_variable_keys.map(&:to_s).sort).to eq(update.dig(:expectedAfterReplacement, :variables).sort) + + dependency = globals[:dependencyUpdateCase] + dependency[:modes].each do |mode| + sdk = Featurevisor.create_featurevisor(datafile: dependency[:initial], log_level: "fatal") + captured = [] + sdk.on("datafile_set", ->(event) { captured << event }) + sdk.set_datafile(dependency[:updated], mode[:replace]) + expect(captured.last[:features].map(&:to_s)).to match_array(dependency[:expectedChangedFeatures]) + expect(captured.last[:variables].map(&:to_s)).to match_array(dependency[:expectedChangedVariables]) + end + + sdk = Featurevisor.create_featurevisor(datafile: dependency[:initial], log_level: "fatal") + captured = [] + sdk.on("datafile_set", ->(event) { captured << event }) + sdk.set_datafile(dependency[:withoutSegment], true) + expect(captured.last[:features].map(&:to_s)).to match_array(dependency[:expectedRemovedSegmentFeatures]) + expect(captured.last[:variables].map(&:to_s)).to match_array(dependency[:expectedRemovedSegmentVariables]) + end + + it "keeps child sticky variables isolated and runs unified module callbacks" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) + datafile = fixture.dig(:globalVariables, :datafile) + callbacks = [] + f = Featurevisor.create_featurevisor( + datafile: datafile, + sticky_variables: { stringValue: "parent" }, + log_level: "fatal", + modules: [{ + name: "global-callbacks", + before_evaluation: ->(options) { callbacks << [:before, options[:feature_key]]; options }, + after_evaluation: ->(evaluation, _options) { callbacks << [:after, evaluation[:feature_key]]; evaluation } + }] + ) + child = f.spawn({}, sticky_variables: { stringValue: "child" }) + expect(child.get_variable("stringValue")).to eq("child") + child.set_sticky_variables({ stringValue: "changed" }, true) + expect(child.get_variable("stringValue")).to eq("changed") + expect(f.get_variable("stringValue")).to eq("parent") + expect(callbacks).to include([:before, nil], [:after, nil]) + end end diff --git a/spec/emitter_spec.rb b/spec/emitter_spec.rb index cff1516..796f1da 100644 --- a/spec/emitter_spec.rb +++ b/spec/emitter_spec.rb @@ -27,7 +27,7 @@ expect(handled_details[0]).to eq({ key: "value" }) # trigger unsubscribed event - emitter.trigger("sticky_set", { key: "value2" }) + emitter.trigger("sticky_features_set", { key: "value2" }) expect(handled_details.length).to eq(1) # unsubscribe @@ -75,8 +75,8 @@ metadata: { version: "1.0.0", environment: "production" } } - emitter.on("sticky_set", handle_details) - emitter.trigger("sticky_set", complex_details) + emitter.on("sticky_features_set", handle_details) + emitter.trigger("sticky_features_set", complex_details) expect(handled_details.length).to eq(1) expect(handled_details[0]).to eq(complex_details) @@ -86,16 +86,16 @@ calls = [] unsubscribe_second = nil - emitter.on("sticky_set", ->(_details) { + emitter.on("sticky_features_set", ->(_details) { calls << "first" unsubscribe_second.call }) - unsubscribe_second = emitter.on("sticky_set", ->(_details) { + unsubscribe_second = emitter.on("sticky_features_set", ->(_details) { calls << "second" }) - emitter.trigger("sticky_set") - emitter.trigger("sticky_set") + emitter.trigger("sticky_features_set") + emitter.trigger("sticky_features_set") expect(calls).to eq(%w[first second first]) end @@ -166,7 +166,7 @@ it "should remove all listeners from all events" do emitter.on("datafile_set", handle_details) emitter.on("context_set", handle_details) - emitter.on("sticky_set", handle_details) + emitter.on("sticky_features_set", handle_details) expect(emitter.listeners.keys.length).to eq(3) @@ -190,7 +190,7 @@ describe "constants" do it "should have correct event names" do - expect(Featurevisor::EVENT_NAMES).to eq(%w[datafile_set context_set sticky_set error]) + expect(Featurevisor::EVENT_NAMES).to eq(%w[datafile_set context_set sticky_features_set sticky_variables_set error]) end end diff --git a/spec/events_spec.rb b/spec/events_spec.rb index ba8c89a..c3e613f 100644 --- a/spec/events_spec.rb +++ b/spec/events_spec.rb @@ -3,7 +3,7 @@ RSpec.describe Featurevisor::Events do let(:diagnostics) { Featurevisor.const_get(:DiagnosticReporter).new(level: "error") } - describe ".get_params_for_sticky_set_event" do + describe ".get_params_for_sticky_features_set_event" do it "should get params for sticky set event: empty to new" do previous_sticky_features = {} new_sticky_features = { @@ -12,7 +12,7 @@ } replace = true - result = described_class.get_params_for_sticky_set_event( + result = described_class.get_params_for_sticky_features_set_event( previous_sticky_features, new_sticky_features, replace @@ -35,7 +35,7 @@ } replace = true - result = described_class.get_params_for_sticky_set_event( + result = described_class.get_params_for_sticky_features_set_event( previous_sticky_features, new_sticky_features, replace @@ -78,6 +78,7 @@ def build_reader(revision:, features:) previousRevision: "1", revisionChanged: true, features: %i[feature1 feature2], + variables: [], replaced: false }) end @@ -106,6 +107,7 @@ def build_reader(revision:, features:) previousRevision: "1", revisionChanged: true, features: %i[feature2 feature3], + variables: [], replaced: false }) end @@ -132,6 +134,7 @@ def build_reader(revision:, features:) previousRevision: "1", revisionChanged: true, features: %i[feature1 feature2], + variables: [], replaced: false }) end diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index 36de8a6..45da7ec 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -30,11 +30,11 @@ ) sdk.set_datafile(schemaVersion: "2", revision: "1", segments: {}, features: {}) - sdk.set_sticky(test: { enabled: true }) + sdk.set_sticky_features(test: { enabled: true }) sdk.set_context(country: "nl") codes = diagnostics.map { |diagnostic| diagnostic[:code] } - expect(codes).to include("datafile_set", "sticky_set", "context_set") + expect(codes).to include("datafile_set", "sticky_features_set", "context_set") end it "should configure plain bucketBy" do @@ -333,7 +333,7 @@ } sdk = Featurevisor.create_featurevisor( - sticky: { + sticky_features: { test: { enabled: true, variation: "control", @@ -367,7 +367,7 @@ ).to eq("control") # unsetting sticky features will make it treatment - sdk.set_sticky({}, true) + sdk.set_sticky_features({}, true) expect( sdk.get_variation("test", { userId: "123" @@ -968,7 +968,7 @@ userId: "123" } - evaluated_features = sdk.get_all_evaluations(context) + evaluated_features = sdk.get_feature_evaluations(context) expect(evaluated_features).to eq({ test: { enabled: true, diff --git a/spec/modules_spec.rb b/spec/modules_spec.rb index b5a0f3f..692735a 100644 --- a/spec/modules_spec.rb +++ b/spec/modules_spec.rb @@ -279,6 +279,29 @@ expect(result[:original]).to be true end + it "runs canonical module phases in order" do + order = [] + %w[first second].each do |name| + modules_manager.add(Featurevisor::Modules::FeaturevisorModule.new( + name: name, + before: ->(options) { order << "before:#{name}"; options }, + before_evaluation: ->(options) { order << "beforeEvaluation:#{name}"; options }, + after_evaluation: ->(evaluation, _options) { order << "afterEvaluation:#{name}"; evaluation }, + after: ->(evaluation, _options) { order << "after:#{name}"; evaluation } + )) + end + + options = modules_manager.run_before_modules(type: "flag", feature_key: "test") + modules_manager.run_after_modules({ type: "flag", feature_key: "test" }, options) + + expect(order).to eq([ + "before:first", "before:second", + "beforeEvaluation:first", "beforeEvaluation:second", + "afterEvaluation:first", "afterEvaluation:second", + "after:first", "after:second" + ]) + end + it "should initialize with existing modules" do mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") manager = Featurevisor::Modules::ModulesManager.new(modules: [mod], diagnostics: diagnostics) diff --git a/spec/openfeature_provider_spec.rb b/spec/openfeature_provider_spec.rb index bbfe275..ea7fe79 100644 --- a/spec/openfeature_provider_spec.rb +++ b/spec/openfeature_provider_spec.rb @@ -17,6 +17,10 @@ force: [{ conditions: { attribute: "userId", operator: "equals", value: "forced-user" }, enabled: true, variation: "on" }], traffic: [{ key: "all", segments: "*", percentage: 100_000, variation: "on" }] } + }, + variables: { + welcomeMessage: { type: "string", defaultValue: "Welcome" }, + globalEnabled: { type: "boolean", defaultValue: true } } } end @@ -43,6 +47,20 @@ def provider(**options) expect(provider.fetch_object_value(flag_key: "checkout:items", default_value: [], evaluation_context: context).value).to eq(["a"]) expect(provider.fetch_object_value(flag_key: "checkout:config", default_value: {}, evaluation_context: context).value).to eq({ "color" => "blue" }) expect(provider.fetch_object_value(flag_key: "checkout:json", default_value: {}, evaluation_context: context).value).to eq({ "nested" => true }) + expect(provider.fetch_string_value(flag_key: "variable:welcomeMessage", default_value: "fallback", evaluation_context: context).value).to eq("Welcome") + expect(provider.fetch_boolean_value(flag_key: "variable:globalEnabled", default_value: false, evaluation_context: context).value).to be(true) + end + + it "supports a custom global variable prefix" do + instance = provider(key_separator: "/", global_variable_prefix: "$global") + expect(instance.fetch_string_value(flag_key: "$global/welcomeMessage", default_value: "fallback").value).to eq("Welcome") + expect(instance.fetch_string_value(flag_key: "variable/welcomeMessage", default_value: "fallback").error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) + ensure + instance&.shutdown + end + + it "rejects a global variable prefix containing the separator" do + expect { provider(global_variable_prefix: "global:value") }.to raise_error(ArgumentError) end it "supports errors, custom grammar, tracking, and shutdown" do @@ -147,6 +165,7 @@ def provider(**options) "disabled" => OpenFeature::SDK::Provider::Reason::DISABLED, "variation_disabled" => OpenFeature::SDK::Provider::Reason::DISABLED, "variable_disabled" => OpenFeature::SDK::Provider::Reason::DISABLED, + "required_features_unmet" => OpenFeature::SDK::Provider::Reason::DISABLED, "out_of_range" => OpenFeature::SDK::Provider::Reason::DEFAULT, "no_match" => OpenFeature::SDK::Provider::Reason::DEFAULT, "variable_default" => OpenFeature::SDK::Provider::Reason::DEFAULT