diff --git a/README.md b/README.md index d48810f..14766ba 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,9 @@ require 'json' # Fetch datafile from URL datafile_url = 'https://cdn.yoursite.com/datafile.json' response = Net::HTTP.get_response(URI(datafile_url)) -datafile_content = JSON.parse(response.body) + +# Parse JSON with symbolized keys (required) +datafile_content = JSON.parse(response.body, symbolize_names: true) # Create SDK instance f = Featurevisor.create_instance( @@ -89,6 +91,19 @@ f = Featurevisor.create_instance( ) ``` +**Important**: When parsing JSON datafiles, you must use `symbolize_names: true` to ensure proper key handling by the SDK. + +Alternatively, you can pass a JSON string directly and the SDK will parse it automatically: + +```ruby +# Option 1: Parse JSON yourself (recommended) +datafile_content = JSON.parse(json_string, symbolize_names: true) +f = Featurevisor.create_instance(datafile: datafile_content) + +# Option 2: Pass JSON string directly (automatic parsing) +f = Featurevisor.create_instance(datafile: json_string) +``` + ## Evaluation types We can evaluate 3 types of values against a particular [feature](https://featurevisor.com/docs/features/): @@ -334,9 +349,16 @@ f.set_sticky({ You may also initialize the SDK without passing `datafile`, and set it later on: ```ruby +# Parse with symbolized keys before setting +datafile_content = JSON.parse(json_string, symbolize_names: true) f.set_datafile(datafile_content) + +# Or pass JSON string directly for automatic parsing +f.set_datafile(json_string) ``` +**Important**: When calling `set_datafile()`, ensure JSON is parsed with `symbolize_names: true` if you're parsing it yourself. + ### Updating datafile You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly. diff --git a/bin/commands/assess_distribution.rb b/bin/commands/assess_distribution.rb index 6577518..cf72f00 100644 --- a/bin/commands/assess_distribution.rb +++ b/bin/commands/assess_distribution.rb @@ -58,8 +58,8 @@ def run # Initialize evaluation counters flag_evaluations = { - "enabled" => 0, - "disabled" => 0 + enabled: 0, + disabled: 0 } variation_evaluations = {} @@ -78,9 +78,9 @@ def run # Evaluate flag flag_evaluation = instance.is_enabled(@options.feature, context_copy) if flag_evaluation - flag_evaluations["enabled"] += 1 + flag_evaluations[:enabled] += 1 else - flag_evaluations["disabled"] += 1 + flag_evaluations[:disabled] += 1 end # Evaluate variation if feature has variations @@ -147,7 +147,7 @@ def build_datafile(environment) end begin - JSON.parse(stdout) + JSON.parse(stdout, symbolize_names: true) rescue JSON::ParserError => e puts "Error: Failed to parse datafile JSON: #{e.message}" exit 1 @@ -160,27 +160,13 @@ def execute_command(command) end def create_instance(datafile) - # Convert datafile to proper format for the SDK - symbolized_datafile = symbolize_keys(datafile) - # Create SDK instance Featurevisor.create_instance( - datafile: symbolized_datafile, + datafile: datafile, log_level: get_logger_level ) end - def symbolize_keys(obj) - case obj - when Hash - obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) } - when Array - obj.map { |item| symbolize_keys(item) } - else - obj - end - end - def get_logger_level if @options.verbose "debug" diff --git a/bin/commands/benchmark.rb b/bin/commands/benchmark.rb index fb4dbf6..987790d 100644 --- a/bin/commands/benchmark.rb +++ b/bin/commands/benchmark.rb @@ -120,7 +120,7 @@ def build_datafile(environment) # Parse the JSON output begin - JSON.parse(datafile_output) + JSON.parse(datafile_output, symbolize_names: true) rescue JSON::ParserError => e puts "Error: Failed to parse datafile JSON: #{e.message}" puts "Command output: #{datafile_output}" @@ -143,16 +143,13 @@ def execute_command(command) end def create_instance(datafile) - # Convert string keys to symbols for the SDK - symbolized_datafile = symbolize_keys(datafile) - # Create a real Featurevisor instance instance = Featurevisor.create_instance( log_level: get_logger_level ) # Explicitly set the datafile - instance.set_datafile(symbolized_datafile) + instance.set_datafile(datafile) instance end @@ -167,17 +164,6 @@ def get_logger_level end end - def symbolize_keys(obj) - case obj - when Hash - obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) } - when Array - obj.map { |item| symbolize_keys(item) } - else - obj - end - end - def benchmark_feature_flag(instance, feature_key, context, n) start_time = Time.now diff --git a/bin/commands/test.rb b/bin/commands/test.rb index 7a0508f..66df962 100644 --- a/bin/commands/test.rb +++ b/bin/commands/test.rb @@ -19,13 +19,13 @@ def run # Get project configuration config = get_config - environments = config["environments"] || [] + environments = config[:environments] || [] segments_by_key = get_segments # Use CLI schemaVersion option or fallback to config schema_version = @options.schema_version if schema_version.nil? || schema_version.empty? - schema_version = config["schemaVersion"] + schema_version = config[:schemaVersion] end # Build datafiles for all environments @@ -57,7 +57,7 @@ def get_config config_output = execute_command(command) begin - JSON.parse(config_output) + JSON.parse(config_output, symbolize_names: true) rescue JSON::ParserError => e puts "Error: Failed to parse config JSON: #{e.message}" puts "Command output: #{config_output}" @@ -71,11 +71,11 @@ def get_segments segments_output = execute_command(command) begin - segments = JSON.parse(segments_output) + segments = JSON.parse(segments_output, symbolize_names: true) segments_by_key = {} segments.each do |segment| - if segment["key"] - segments_by_key[segment["key"]] = segment + if segment[:key] + segments_by_key[segment[:key]] = segment end end segments_by_key @@ -110,7 +110,7 @@ def build_datafiles(environments, schema_version, inflate) datafile_output = execute_command(command) begin - datafile = JSON.parse(datafile_output) + datafile = JSON.parse(datafile_output, symbolize_names: true) datafiles_by_environment[environment] = datafile rescue JSON::ParserError => e puts "Error: Failed to parse datafile JSON for #{environment}: #{e.message}" @@ -147,7 +147,7 @@ def get_tests tests_output = execute_command(command) begin - JSON.parse(tests_output) + JSON.parse(tests_output, symbolize_names: true) rescue JSON::ParserError => e puts "Error: Failed to parse tests JSON: #{e.message}" puts "Command output: #{tests_output}" @@ -161,12 +161,9 @@ def create_sdk_instances(environments, datafiles_by_environment, level) environments.each do |environment| datafile = datafiles_by_environment[environment] - # Convert string keys to symbols for the SDK - symbolized_datafile = symbolize_keys(datafile) - # Create SDK instance instance = Featurevisor.create_instance( - datafile: symbolized_datafile, + datafile: datafile, log_level: level, hooks: [ { @@ -189,8 +186,8 @@ def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, seg failed_assertions_count = 0 tests.each do |test| - test_key = test["key"] - assertions = test["assertions"] || [] + test_key = test[:key] + assertions = test[:assertions] || [] results = "" test_has_error = false test_duration = 0.0 @@ -199,8 +196,8 @@ def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, seg if assertion.is_a?(Hash) test_result = nil - if test["feature"] - environment = assertion["environment"] + if test[:feature] + environment = assertion[:environment] instance = sdk_instances_by_environment[environment] # Show datafile if requested @@ -212,12 +209,11 @@ def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, seg end # If "at" parameter is provided, create a new instance with the specific hook - if assertion["at"] + if assertion[:at] datafile = datafiles_by_environment[environment] - symbolized_datafile = symbolize_keys(datafile) instance = Featurevisor.create_instance( - datafile: symbolized_datafile, + datafile: datafile, log_level: level, hooks: [ { @@ -225,7 +221,7 @@ def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, seg bucket_value: ->(options) do # Match JavaScript implementation: assertion.at * (MAX_BUCKETED_NUMBER / 100) # MAX_BUCKETED_NUMBER is 100000, so this becomes assertion.at * 1000 - at = assertion["at"] + at = assertion[:at] if at.is_a?(Numeric) (at * 1000).to_i else @@ -237,9 +233,9 @@ def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, seg ) end - test_result = run_test_feature(assertion, test["feature"], instance, level) - elsif test["segment"] - segment_key = test["segment"] + test_result = run_test_feature(assertion, test[:feature], instance, level) + elsif test[:segment] + segment_key = test[:segment] segment = segments_by_key[segment_key] if segment.is_a?(Hash) test_result = run_test_segment(assertion, segment, level) @@ -250,12 +246,12 @@ def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, seg test_duration += test_result[:duration] if test_result[:has_error] - results += " ✘ #{assertion['description']} (#{(test_result[:duration] * 1000).round(2)}ms)\n" + results += " ✘ #{assertion[:description]} (#{(test_result[:duration] * 1000).round(2)}ms)\n" results += test_result[:errors] test_has_error = true failed_assertions_count += 1 else - results += " ✔ #{assertion['description']} (#{(test_result[:duration] * 1000).round(2)}ms)\n" + results += " ✔ #{assertion[:description]} (#{(test_result[:duration] * 1000).round(2)}ms)\n" passed_assertions_count += 1 end end @@ -285,8 +281,8 @@ def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, seg end def run_test_feature(assertion, feature_key, instance, level) - context = parse_context(assertion["context"]) - sticky = parse_sticky(assertion["sticky"]) + context = parse_context(assertion[:context]) + sticky = parse_sticky(assertion[:sticky]) # Set context and sticky for this assertion instance.set_context(context, false) @@ -302,8 +298,8 @@ def run_test_feature(assertion, feature_key, instance, level) start_time = Time.now # Test expectedToBeEnabled - if assertion.key?("expectedToBeEnabled") - expected_to_be_enabled = assertion["expectedToBeEnabled"] + if assertion.key?(:expectedToBeEnabled) + expected_to_be_enabled = assertion[:expectedToBeEnabled] is_enabled = instance.is_enabled(feature_key, context, override_options) if is_enabled != expected_to_be_enabled @@ -313,8 +309,8 @@ def run_test_feature(assertion, feature_key, instance, level) end # Test expectedVariation - if assertion.key?("expectedVariation") - expected_variation = assertion["expectedVariation"] + if assertion.key?(:expectedVariation) + expected_variation = assertion[:expectedVariation] variation = instance.get_variation(feature_key, context, override_options) variation_value = variation.nil? ? nil : variation @@ -325,12 +321,12 @@ def run_test_feature(assertion, feature_key, instance, level) end # Test expectedVariables - if assertion["expectedVariables"] - expected_variables = assertion["expectedVariables"] + if assertion[:expectedVariables] + expected_variables = assertion[:expectedVariables] expected_variables.each do |variable_key, expected_value| # Set default variable value for this specific variable - if assertion["defaultVariableValues"] && assertion["defaultVariableValues"][variable_key] - override_options[:default_variable_value] = assertion["defaultVariableValues"][variable_key] + if assertion[:defaultVariableValues] && assertion[:defaultVariableValues][variable_key] + override_options[:default_variable_value] = assertion[:defaultVariableValues][variable_key] end actual_value = instance.get_variable(feature_key, variable_key, context, override_options) @@ -369,13 +365,13 @@ def run_test_feature(assertion, feature_key, instance, level) end # Test expectedEvaluations - if assertion["expectedEvaluations"] - expected_evaluations = assertion["expectedEvaluations"] + if assertion[:expectedEvaluations] + expected_evaluations = assertion[:expectedEvaluations] # Test flag evaluations - if expected_evaluations["flag"] + if expected_evaluations[:flag] evaluation = instance.evaluate_flag(feature_key, context, override_options) - expected_evaluations["flag"].each do |key, expected_value| + expected_evaluations[:flag].each do |key, expected_value| actual_value = get_evaluation_value(evaluation, key) if !compare_values(actual_value, expected_value) has_error = true @@ -385,9 +381,9 @@ def run_test_feature(assertion, feature_key, instance, level) end # Test variation evaluations - if expected_evaluations["variation"] + if expected_evaluations[:variation] evaluation = instance.evaluate_variation(feature_key, context, override_options) - expected_evaluations["variation"].each do |key, expected_value| + expected_evaluations[:variation].each do |key, expected_value| actual_value = get_evaluation_value(evaluation, key) if !compare_values(actual_value, expected_value) has_error = true @@ -397,8 +393,8 @@ def run_test_feature(assertion, feature_key, instance, level) end # Test variable evaluations - if expected_evaluations["variables"] - expected_evaluations["variables"].each do |variable_key, expected_eval| + if expected_evaluations[:variables] + expected_evaluations[:variables].each do |variable_key, expected_eval| if expected_eval.is_a?(Hash) evaluation = instance.evaluate_variable(feature_key, variable_key, context, override_options) expected_eval.each do |key, expected_value| @@ -414,10 +410,10 @@ def run_test_feature(assertion, feature_key, instance, level) end # Test children - if assertion["children"] - assertion["children"].each do |child| + if assertion[:children] + assertion[:children].each do |child| if child.is_a?(Hash) - child_context = parse_context(child["context"]) + child_context = parse_context(child[:context]) # Create override options for child with sticky values child_override_options = create_override_options(child) @@ -452,7 +448,7 @@ def run_test_feature(assertion, feature_key, instance, level) end def run_test_feature_child(assertion, feature_key, instance, level) - context = parse_context(assertion["context"]) + context = parse_context(assertion[:context]) override_options = create_override_options(assertion) has_error = false @@ -460,8 +456,8 @@ def run_test_feature_child(assertion, feature_key, instance, level) start_time = Time.now # Test expectedToBeEnabled - if assertion.key?("expectedToBeEnabled") - expected_to_be_enabled = assertion["expectedToBeEnabled"] + if assertion.key?(:expectedToBeEnabled) + expected_to_be_enabled = assertion[:expectedToBeEnabled] is_enabled = instance.is_enabled(feature_key, context, override_options) if is_enabled != expected_to_be_enabled @@ -471,8 +467,8 @@ def run_test_feature_child(assertion, feature_key, instance, level) end # Test expectedVariation - if assertion.key?("expectedVariation") - expected_variation = assertion["expectedVariation"] + if assertion.key?(:expectedVariation) + expected_variation = assertion[:expectedVariation] variation = instance.get_variation(feature_key, context, override_options) variation_value = variation.nil? ? nil : variation @@ -483,12 +479,12 @@ def run_test_feature_child(assertion, feature_key, instance, level) end # Test expectedVariables - if assertion["expectedVariables"] - expected_variables = assertion["expectedVariables"] + if assertion[:expectedVariables] + expected_variables = assertion[:expectedVariables] expected_variables.each do |variable_key, expected_value| # Set default variable value for this specific variable - if assertion["defaultVariableValues"] && assertion["defaultVariableValues"][variable_key] - override_options[:default_variable_value] = assertion["defaultVariableValues"][variable_key] + if assertion[:defaultVariableValues] && assertion[:defaultVariableValues][variable_key] + override_options[:default_variable_value] = assertion[:defaultVariableValues][variable_key] end actual_value = instance.get_variable(feature_key, variable_key, context, override_options) @@ -536,8 +532,8 @@ def run_test_feature_child(assertion, feature_key, instance, level) end def run_test_segment(assertion, segment, level) - context = parse_context(assertion["context"]) - conditions = segment["conditions"] + context = parse_context(assertion[:context]) + conditions = segment[:conditions] # Create a minimal datafile for segment testing datafile = { @@ -549,7 +545,7 @@ def run_test_segment(assertion, segment, level) # Create SDK instance for segment testing instance = Featurevisor.create_instance( - datafile: symbolize_keys(datafile), + datafile: datafile, log_level: level ) @@ -557,8 +553,8 @@ def run_test_segment(assertion, segment, level) errors = "" start_time = Time.now - if assertion.key?("expectedToMatch") - expected_to_match = assertion["expectedToMatch"] + if assertion.key?(:expectedToMatch) + expected_to_match = assertion[:expectedToMatch] actual = instance.instance_variable_get(:@datafile_reader).all_conditions_are_matched(conditions, context) if actual != expected_to_match @@ -593,16 +589,16 @@ def parse_sticky(sticky_data) if value.is_a?(Hash) evaluated_feature = {} - if value.key?("enabled") - evaluated_feature[:enabled] = value["enabled"] + if value.key?(:enabled) + evaluated_feature[:enabled] = value[:enabled] end - if value.key?("variation") - evaluated_feature[:variation] = value["variation"] + if value.key?(:variation) + evaluated_feature[:variation] = value[:variation] end - if value["variables"] && value["variables"].is_a?(Hash) - evaluated_feature[:variables] = value["variables"].transform_keys(&:to_sym) + if value[:variables] && value[:variables].is_a?(Hash) + evaluated_feature[:variables] = value[:variables].transform_keys(&:to_sym) end sticky_features[key.to_sym] = evaluated_feature @@ -618,8 +614,8 @@ def parse_sticky(sticky_data) def create_override_options(assertion) options = {} - if assertion["defaultVariationValue"] - options[:default_variation_value] = assertion["defaultVariationValue"] + if assertion[:defaultVariationValue] + options[:default_variation_value] = assertion[:defaultVariationValue] end options @@ -627,41 +623,41 @@ def create_override_options(assertion) def get_evaluation_value(evaluation, key) case key - when "type" + when :type evaluation[:type] - when "featureKey" + when :featureKey evaluation[:feature_key] - when "reason" + when :reason evaluation[:reason] - when "bucketKey" + when :bucketKey evaluation[:bucket_key] - when "bucketValue" + when :bucketValue evaluation[:bucket_value] - when "ruleKey" + when :ruleKey evaluation[:rule_key] - when "error" + when :error evaluation[:error] - when "enabled" + when :enabled evaluation[:enabled] - when "traffic" + when :traffic evaluation[:traffic] - when "forceIndex" + when :forceIndex evaluation[:force_index] - when "force" + when :force evaluation[:force] - when "required" + when :required evaluation[:required] - when "sticky" + when :sticky evaluation[:sticky] - when "variation" + when :variation evaluation[:variation] - when "variationValue" + when :variationValue evaluation[:variation_value] - when "variableKey" + when :variableKey evaluation[:variable_key] - when "variableValue" + when :variableValue evaluation[:variable_value] - when "variableSchema" + when :variableSchema evaluation[:variable_schema] else nil @@ -765,16 +761,7 @@ def normalize_hash_keys(obj) end end - def symbolize_keys(obj) - case obj - when Hash - obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) } - when Array - obj.map { |v| symbolize_keys(v) } - else - obj - end - end + def execute_command(command) stdout, stderr, status = Open3.capture3(command) diff --git a/lib/featurevisor/conditions.rb b/lib/featurevisor/conditions.rb index b4c78e3..74170cd 100644 --- a/lib/featurevisor/conditions.rb +++ b/lib/featurevisor/conditions.rb @@ -53,12 +53,12 @@ def self.condition_is_matched(condition, context, get_regex) if value.is_a?(Array) && (context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Numeric) || context_value_from_path.nil?) # Check if the attribute key actually exists in the context key_exists = context.key?(attribute.to_sym) || context.key?(attribute.to_s) - + # If key doesn't exist, notIn should fail (return false), in should also fail if !key_exists return false end - + value_in_context = context_value_from_path.to_s if operator == "in" diff --git a/lib/featurevisor/datafile_reader.rb b/lib/featurevisor/datafile_reader.rb index 14cb9e5..7a8f91f 100644 --- a/lib/featurevisor/datafile_reader.rb +++ b/lib/featurevisor/datafile_reader.rb @@ -183,6 +183,8 @@ def all_conditions_are_matched(conditions, context) end end + + if conditions.is_a?(Array) return conditions.all? { |c| all_conditions_are_matched(c, context) } end @@ -245,6 +247,8 @@ def all_segments_are_matched(group_segments, context) all_segments_are_matched({ "and" => group_segments["not"] }, context) == false end end + + end if group_segments.is_a?(Array) diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index 3062bb8..3e8a66d 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -42,7 +42,7 @@ def initialize(options = {}) if options[:datafile] @datafile_reader = Featurevisor::DatafileReader.new( - datafile: options[:datafile].is_a?(String) ? JSON.parse(options[:datafile]) : options[:datafile], + datafile: options[:datafile].is_a?(String) ? JSON.parse(options[:datafile], symbolize_names: true) : options[:datafile], logger: @logger ) end @@ -61,7 +61,7 @@ def set_log_level(level) def set_datafile(datafile) begin new_datafile_reader = Featurevisor::DatafileReader.new( - datafile: datafile.is_a?(String) ? JSON.parse(datafile) : datafile, + datafile: datafile.is_a?(String) ? JSON.parse(datafile, symbolize_names: true) : datafile, logger: @logger ) diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index c939bc2..9b6b1ca 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -1197,4 +1197,105 @@ expect(sdk.is_enabled("test", { userId: "123", country: "nl" })).to be true expect(sdk.is_enabled("test", { userId: "123", country: "us", device: "iphone" })).to be true end + + it "should handle JSON string datafile with automatic parsing" do + json_datafile = '{ + "schemaVersion": "2", + "revision": "1.0", + "features": { + "test": { + "key": "test", + "bucketBy": "userId", + "variations": [ + { "value": "control" }, + { "value": "treatment" } + ], + "traffic": [ + { + "key": "1", + "segments": "*", + "percentage": 100000, + "allocation": [ + { "variation": "control", "range": [0, 100000] }, + { "variation": "treatment", "range": [0, 0] } + ] + } + ] + } + }, + "segments": {} + }' + + sdk = Featurevisor.create_instance(datafile: json_datafile) + + expect(sdk.get_revision).to eq("1.0") + expect(sdk.get_feature("test")).to be_a(Hash) + expect(sdk.get_feature("test")[:key]).to eq("test") + expect(sdk.get_feature("test")[:bucketBy]).to eq("userId") + expect(sdk.is_enabled("test", { userId: "123" })).to be true + expect(sdk.get_variation("test", { userId: "123" })).to eq("control") + end + + it "should handle JSON string when setting datafile" do + sdk = Featurevisor.create_instance + + json_datafile = '{ + "schemaVersion": "2", + "revision": "2.0", + "features": { + "newFeature": { + "key": "newFeature", + "bucketBy": "userId", + "traffic": [ + { + "key": "1", + "segments": "*", + "percentage": 100000, + "allocation": [] + } + ] + } + }, + "segments": {} + }' + + sdk.set_datafile(json_datafile) + + expect(sdk.get_revision).to eq("2.0") + expect(sdk.get_feature("newFeature")).to be_a(Hash) + expect(sdk.get_feature("newFeature")[:key]).to eq("newFeature") + expect(sdk.get_feature("newFeature")[:bucketBy]).to eq("userId") + expect(sdk.is_enabled("newFeature", { userId: "123" })).to be true + end + + it "should work with manually parsed JSON using symbolize_names: true" do + json_string = '{ + "schemaVersion": "2", + "revision": "3.0", + "features": { + "manualTest": { + "key": "manualTest", + "bucketBy": "userId", + "traffic": [ + { + "key": "1", + "segments": "*", + "percentage": 100000, + "allocation": [] + } + ] + } + }, + "segments": {} + }' + + # Parse with symbolize_names: true as documented + datafile = JSON.parse(json_string, symbolize_names: true) + sdk = Featurevisor.create_instance(datafile: datafile) + + expect(sdk.get_revision).to eq("3.0") + expect(sdk.get_feature("manualTest")).to be_a(Hash) + expect(sdk.get_feature("manualTest")[:key]).to eq("manualTest") + expect(sdk.is_enabled("manualTest", { userId: "123" })).to be true + end end