diff --git a/.circleci/config.yml b/.circleci/config.yml index 7ec25b1a..0e28431e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,9 +1,13 @@ version: 2.1 +orbs: + win: circleci/windows@4.1.1 + workflows: version: 2 test: jobs: + - build-test-windows - build-test-linux: name: Ruby 2.5 docker-image: cimg/ruby:2.5 @@ -22,6 +26,67 @@ workflows: jruby: true jobs: + build-test-windows: + executor: + name: win/default + + steps: + - checkout + + - run: + name: "Setup DynamoDB" + command: | + iwr -outf dynamo.zip https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.zip + mkdir dynamo + Expand-Archive -Path dynamo.zip -DestinationPath dynamo + - run: + name: "Run DynamoDB" + background: true + working_directory: dynamo + command: javaw -D"java.library.path=./DynamoDBLocal_lib" -jar DynamoDBLocal.jar + + - run: + name: "Setup Consul" + command: | + iwr -outf consul.zip https://releases.hashicorp.com/consul/1.4.2/consul_1.4.2_windows_amd64.zip + mkdir consul + Expand-Archive -Path consul.zip -DestinationPath consul + sc.exe create "Consul" binPath="C:/Users/circleci/project/consul/consul.exe agent -dev" + - run: + name: "Run Consul" + background: true + working_directory: consul + command: sc.exe start "Consul" + + - run: + name: "Setup Redis" + command: | + iwr -outf redis.zip https://github.com/MicrosoftArchive/redis/releases/download/win-3.0.504/Redis-x64-3.0.504.zip + mkdir redis + Expand-Archive -Path redis.zip -DestinationPath redis + cd redis + ./redis-server --service-install + - run: + name: "Run Redis" + background: true + working_directory: redis + command: | + ./redis-server --service-start + + - run: ruby -v + - run: choco install msys2 --allow-downgrade -y --version 20200903.0.0 + - run: ridk.cmd exec pacman -S --noconfirm --needed base-devel mingw-w64-x86_64-toolchain + + - run: gem install bundler -v 2.2.33 + - run: bundle _2.2.33_ install + - run: mkdir /tmp/circle-artifacts + - run: bundle _2.2.33_ exec rspec --format documentation --format RspecJunitFormatter -o /tmp/circle-artifacts/rspec.xml spec + + - store_test_results: + path: /tmp/circle-artifacts + - store_artifacts: + path: /tmp/circle-artifacts + build-test-linux: parameters: docker-image: diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 88296f02..00000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,51 +0,0 @@ -jobs: - - job: build - pool: - vmImage: 'vs2017-win2016' - steps: - - task: PowerShell@2 - displayName: 'Setup Dynamo' - inputs: - targetType: inline - workingDirectory: $(System.DefaultWorkingDirectory) - script: | - iwr -outf dynamo.zip https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.zip - mkdir dynamo - Expand-Archive -Path dynamo.zip -DestinationPath dynamo - cd dynamo - javaw -D"java.library.path=./DynamoDBLocal_lib" -jar DynamoDBLocal.jar - - task: PowerShell@2 - displayName: 'Setup Consul' - inputs: - targetType: inline - workingDirectory: $(System.DefaultWorkingDirectory) - script: | - iwr -outf consul.zip https://releases.hashicorp.com/consul/1.4.2/consul_1.4.2_windows_amd64.zip - mkdir consul - Expand-Archive -Path consul.zip -DestinationPath consul - cd consul - sc.exe create "Consul" binPath="$(System.DefaultWorkingDirectory)/consul/consul.exe agent -dev" - sc.exe start "Consul" - - task: PowerShell@2 - displayName: 'Setup Redis' - inputs: - targetType: inline - workingDirectory: $(System.DefaultWorkingDirectory) - script: | - iwr -outf redis.zip https://github.com/MicrosoftArchive/redis/releases/download/win-3.0.504/Redis-x64-3.0.504.zip - mkdir redis - Expand-Archive -Path redis.zip -DestinationPath redis - cd redis - ./redis-server --service-install - ./redis-server --service-start - - task: PowerShell@2 - displayName: 'Setup SDK and Test' - inputs: - targetType: inline - workingDirectory: $(System.DefaultWorkingDirectory) - script: | - ruby -v - gem install bundler - bundle install - mkdir rspec - bundle exec rspec --format progress --format RspecJunitFormatter -o ./rspec/rspec.xml spec diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb index 699d8e72..1f5f0fe2 100644 --- a/contract-tests/client_entity.rb +++ b/contract-tests/client_entity.rb @@ -14,6 +14,11 @@ def initialize(log, config) streaming = config[:streaming] opts[:stream_uri] = streaming[:baseUri] if !streaming[:baseUri].nil? opts[:initial_reconnect_delay] = streaming[:initialRetryDelayMs] / 1_000.0 if !streaming[:initialRetryDelayMs].nil? + elsif config[:polling] + polling = config[:polling] + opts[:stream] = false + opts[:base_uri] = polling[:baseUri] if !polling[:baseUri].nil? + opts[:poll_interval] = polling[:pollIntervalMs] / 1_000.0 if !polling[:pollIntervalMs].nil? end if config[:events] @@ -29,6 +34,13 @@ def initialize(log, config) opts[:send_events] = false end + if config[:tags] + opts[:application] = { + :id => config[:tags][:applicationId], + :version => config[:tags][:applicationVersion], + } + end + startWaitTimeMs = config[:startWaitTimeMs] || 5_000 @client = LaunchDarkly::LDClient.new( diff --git a/contract-tests/service.rb b/contract-tests/service.rb index 54cc0b73..68b00288 100644 --- a/contract-tests/service.rb +++ b/contract-tests/service.rb @@ -25,9 +25,11 @@ { capabilities: [ 'server-side', + 'server-side-polling', 'all-flags-with-reasons', 'all-flags-client-side-only', 'all-flags-details-only-for-tracked-flags', + 'tags', ] }.to_json end diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index ed33e08b..15e302ea 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -44,6 +44,7 @@ class Config # @option opts [String] :wrapper_version See {#wrapper_version}. # @option opts [#open] :socket_factory See {#socket_factory}. # @option opts [BigSegmentsConfig] :big_segments See {#big_segments}. + # @option opts [Hash] :application See {#application} # def initialize(opts = {}) @base_uri = (opts[:base_uri] || Config.default_base_uri).chomp("/") @@ -77,6 +78,7 @@ def initialize(opts = {}) @wrapper_version = opts[:wrapper_version] @socket_factory = opts[:socket_factory] @big_segments = opts[:big_segments] || BigSegmentsConfig.new(store: nil) + @application = LaunchDarkly::Impl::Util.validate_application_info(opts[:application] || {}, @logger) end # @@ -284,6 +286,24 @@ def offline? # attr_reader :big_segments + # + # An object that allows configuration of application metadata. + # + # Application metadata may be used in LaunchDarkly analytics or other product features, but does not affect feature flag evaluations. + # + # If you want to set non-default values for any of these fields, provide the appropriately configured hash to the {Config} object. + # + # @example Configuring application information + # opts[:application] = { + # id: "MY APPLICATION ID", + # version: "MY APPLICATION VERSION" + # } + # config = LDConfig.new(opts) + # + # @return [Hash] + # + attr_reader :application + # @deprecated This is replaced by {#data_source}. attr_reader :update_processor diff --git a/lib/ldclient-rb/impl/evaluator.rb b/lib/ldclient-rb/impl/evaluator.rb index ed94719e..e8c9567d 100644 --- a/lib/ldclient-rb/impl/evaluator.rb +++ b/lib/ldclient-rb/impl/evaluator.rb @@ -1,5 +1,6 @@ require "ldclient-rb/evaluation_detail" require "ldclient-rb/impl/evaluator_bucketing" +require "ldclient-rb/impl/evaluator_helpers" require "ldclient-rb/impl/evaluator_operators" module LaunchDarkly @@ -87,19 +88,17 @@ def self.make_big_segment_ref(segment) # method is visible for testing def eval_internal(flag, user, state) if !flag[:on] - return get_off_value(flag, EvaluationReason::off) + return EvaluatorHelpers.off_result(flag) end - prereq_failure_reason = check_prerequisites(flag, user, state) - if !prereq_failure_reason.nil? - return get_off_value(flag, prereq_failure_reason) - end + prereq_failure_result = check_prerequisites(flag, user, state) + return prereq_failure_result if !prereq_failure_result.nil? # Check user target matches (flag[:targets] || []).each do |target| (target[:values] || []).each do |value| if value == user[:key] - return get_variation(flag, target[:variation], EvaluationReason::target_match) + return EvaluatorHelpers.target_match_result(target, flag) end end end @@ -111,13 +110,15 @@ def eval_internal(flag, user, state) if rule_match_user(rule, user, state) reason = rule[:_reason] # try to use cached reason for this rule reason = EvaluationReason::rule_match(i, rule[:id]) if reason.nil? - return get_value_for_variation_or_rollout(flag, rule, user, reason) + return get_value_for_variation_or_rollout(flag, rule, user, reason, + EvaluatorHelpers.rule_precomputed_results(rule)) end end # Check the fallthrough rule if !flag[:fallthrough].nil? - return get_value_for_variation_or_rollout(flag, flag[:fallthrough], user, EvaluationReason::fallthrough) + return get_value_for_variation_or_rollout(flag, flag[:fallthrough], user, EvaluationReason::fallthrough, + EvaluatorHelpers.fallthrough_precomputed_results(flag)) end return EvaluationDetail.new(nil, nil, EvaluationReason::fallthrough) @@ -149,8 +150,7 @@ def check_prerequisites(flag, user, state) end end if !prereq_ok - reason = prerequisite[:_reason] # try to use cached reason - return reason.nil? ? EvaluationReason::prerequisite_failed(prereq_key) : reason + return EvaluatorHelpers.prerequisite_failed_result(prerequisite, flag) end end nil @@ -253,35 +253,26 @@ def segment_rule_match_user(rule, user, segment_key, salt) end private - - def get_variation(flag, index, reason) - if index < 0 || index >= flag[:variations].length - @logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": invalid variation index") - return Evaluator.error_result(EvaluationReason::ERROR_MALFORMED_FLAG) - end - EvaluationDetail.new(flag[:variations][index], index, reason) - end - - def get_off_value(flag, reason) - if flag[:offVariation].nil? # off variation unspecified - return default value - return EvaluationDetail.new(nil, nil, reason) - end - get_variation(flag, flag[:offVariation], reason) - end - - def get_value_for_variation_or_rollout(flag, vr, user, reason) + + def get_value_for_variation_or_rollout(flag, vr, user, reason, precomputed_results) index, in_experiment = EvaluatorBucketing.variation_index_for_user(flag, vr, user) - #if in experiment is true, set reason to a different reason instance/singleton with in_experiment set - if in_experiment && reason.kind == :FALLTHROUGH - reason = EvaluationReason::fallthrough(in_experiment) - elsif in_experiment && reason.kind == :RULE_MATCH - reason = EvaluationReason::rule_match(reason.rule_index, reason.rule_id, in_experiment) - end if index.nil? @logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": variation/rollout object with no variation or rollout") return Evaluator.error_result(EvaluationReason::ERROR_MALFORMED_FLAG) end - return get_variation(flag, index, reason) + if precomputed_results + return precomputed_results.for_variation(index, in_experiment) + else + #if in experiment is true, set reason to a different reason instance/singleton with in_experiment set + if in_experiment + if reason.kind == :FALLTHROUGH + reason = EvaluationReason::fallthrough(in_experiment) + elsif reason.kind == :RULE_MATCH + reason = EvaluationReason::rule_match(reason.rule_index, reason.rule_id, in_experiment) + end + end + return EvaluatorHelpers.evaluation_detail_for_variation(flag, index, reason) + end end end end diff --git a/lib/ldclient-rb/impl/evaluator_helpers.rb b/lib/ldclient-rb/impl/evaluator_helpers.rb new file mode 100644 index 00000000..9629a6aa --- /dev/null +++ b/lib/ldclient-rb/impl/evaluator_helpers.rb @@ -0,0 +1,53 @@ +require "ldclient-rb/evaluation_detail" + +# This file contains any pieces of low-level evaluation logic that don't need to be inside the Evaluator +# class, because they don't depend on any SDK state outside of their input parameters. + +module LaunchDarkly + module Impl + module EvaluatorHelpers + def self.off_result(flag, logger = nil) + pre = flag[:_preprocessed] + pre ? pre.off_result : evaluation_detail_for_off_variation(flag, EvaluationReason::off, logger) + end + + def self.target_match_result(target, flag, logger = nil) + pre = target[:_preprocessed] + pre ? pre.match_result : evaluation_detail_for_variation( + flag, target[:variation], EvaluationReason::target_match, logger) + end + + def self.prerequisite_failed_result(prereq, flag, logger = nil) + pre = prereq[:_preprocessed] + pre ? pre.failed_result : evaluation_detail_for_off_variation( + flag, EvaluationReason::prerequisite_failed(prereq[:key]), logger + ) + end + + def self.fallthrough_precomputed_results(flag) + pre = flag[:_preprocessed] + pre ? pre.fallthrough_factory : nil + end + + def self.rule_precomputed_results(rule) + pre = rule[:_preprocessed] + pre ? pre.all_match_results : nil + end + + def self.evaluation_detail_for_off_variation(flag, reason, logger = nil) + index = flag[:offVariation] + index.nil? ? EvaluationDetail.new(nil, nil, reason) : evaluation_detail_for_variation(flag, index, reason, logger) + end + + def self.evaluation_detail_for_variation(flag, index, reason, logger = nil) + vars = flag[:variations] || [] + if index < 0 || index >= vars.length + logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": invalid variation index") unless logger.nil? + EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) + else + EvaluationDetail.new(vars[index], index, reason) + end + end + end + end +end diff --git a/lib/ldclient-rb/impl/model/preprocessed_data.rb b/lib/ldclient-rb/impl/model/preprocessed_data.rb new file mode 100644 index 00000000..3118ddba --- /dev/null +++ b/lib/ldclient-rb/impl/model/preprocessed_data.rb @@ -0,0 +1,177 @@ +require "ldclient-rb/impl/evaluator_helpers" + +module LaunchDarkly + module Impl + module DataModelPreprocessing + # + # Container for a precomputed result that includes a specific variation index and value, an + # evaluation reason, and optionally an alternate evaluation reason that corresponds to the + # "in experiment" state. + # + class EvalResultsForSingleVariation + def initialize(value, variation_index, regular_reason, in_experiment_reason = nil) + @regular_result = EvaluationDetail.new(value, variation_index, regular_reason) + @in_experiment_result = in_experiment_reason ? + EvaluationDetail.new(value, variation_index, in_experiment_reason) : + @regular_result + end + + # @param in_experiment [Boolean] indicates whether we want the result to include + # "inExperiment: true" in the reason or not + # @return [EvaluationDetail] + def get_result(in_experiment = false) + in_experiment ? @in_experiment_result : @regular_result + end + end + + # + # Container for a set of precomputed results, one for each possible flag variation. + # + class EvalResultFactoryMultiVariations + def initialize(variation_factories) + @factories = variation_factories + end + + # @param index [Integer] the variation index + # @param in_experiment [Boolean] indicates whether we want the result to include + # "inExperiment: true" in the reason or not + def for_variation(index, in_experiment) + if index < 0 || index >= @factories.length + EvaluationDetail.new(nil, nil, EvaluationReason.error(EvaluationReason::ERROR_MALFORMED_FLAG)) + else + @factories[index].get_result(in_experiment) + end + end + end + + # Base class for all of the preprocessed data classes we embed in our data model. Using this class + # ensures that none of its properties will be included in JSON representations. It also overrides + # == to say that it is always equal with another instance of the same class; equality tests on + # this class are only ever done in test code, and we want the contents of these classes to be + # ignored in test code unless we are looking at specific attributes. + class PreprocessedDataBase + def as_json(*) + nil + end + + def to_json(*a) + "null" + end + + def ==(other) + other.class == self.class + end + end + + class FlagPreprocessed < PreprocessedDataBase + def initialize(off_result, fallthrough_factory) + @off_result = off_result + @fallthrough_factory = fallthrough_factory + end + + # @return [EvalResultsForSingleVariation] + attr_reader :off_result + # @return [EvalResultFactoryMultiVariations] + attr_reader :fallthrough_factory + end + + class PrerequisitePreprocessed < PreprocessedDataBase + def initialize(failed_result) + @failed_result = failed_result + end + + # @return [EvalResultsForSingleVariation] + attr_reader :failed_result + end + + class TargetPreprocessed < PreprocessedDataBase + def initialize(match_result) + @match_result = match_result + end + + # @return [EvalResultsForSingleVariation] + attr_reader :match_result + end + + class FlagRulePreprocessed < PreprocessedDataBase + def initialize(all_match_results) + @all_match_results = all_match_results + end + + # @return [EvalResultsForSingleVariation] + attr_reader :all_match_results + end + + class Preprocessor + def initialize(logger = nil) + @logger = logger + end + + def preprocess_item!(kind, item) + if kind.eql? FEATURES + preprocess_flag!(item) + elsif kind.eql? SEGMENTS + preprocess_segment!(item) + end + end + + def preprocess_all_items!(kind, items_map) + return items_map if !items_map + items_map.each do |key, item| + preprocess_item!(kind, item) + end + end + + def preprocess_flag!(flag) + flag[:_preprocessed] = FlagPreprocessed.new( + EvaluatorHelpers.off_result(flag), + precompute_multi_variation_results(flag, EvaluationReason::fallthrough(false), EvaluationReason::fallthrough(true)) + ) + (flag[:prerequisites] || []).each do |prereq| + preprocess_prerequisite!(prereq, flag) + end + (flag[:targets] || []).each do |target| + preprocess_target!(target, flag) + end + rules = flag[:rules] + (rules || []).each_index do |index| + preprocess_flag_rule!(rules[index], index, flag) + end + end + + def preprocess_segment!(segment) + # nothing to do for segments currently + end + + private def preprocess_prerequisite!(prereq, flag) + prereq[:_preprocessed] = PrerequisitePreprocessed.new( + EvaluatorHelpers.prerequisite_failed_result(prereq, flag, @logger) + ) + end + + private def preprocess_target!(target, flag) + target[:_preprocessed] = TargetPreprocessed.new( + EvaluatorHelpers.target_match_result(target, flag, @logger) + ) + end + + private def preprocess_flag_rule!(rule, index, flag) + match_reason = EvaluationReason::rule_match(index, rule[:id]) + match_reason_in_experiment = EvaluationReason::rule_match(index, rule[:id], true) + rule[:_preprocessed] = FlagRulePreprocessed.new( + precompute_multi_variation_results(flag, match_reason, match_reason_in_experiment) + ) + end + + private def precompute_multi_variation_results(flag, regular_reason, in_experiment_reason) + factories = [] + vars = flag[:variations] || [] + vars.each_index do |index| + factories << EvalResultsForSingleVariation.new(vars[index], index, regular_reason, in_experiment_reason) + end + EvalResultFactoryMultiVariations.new(factories) + end + end + end + end +end diff --git a/lib/ldclient-rb/impl/model/serialization.rb b/lib/ldclient-rb/impl/model/serialization.rb index fcf8b135..1d306f46 100644 --- a/lib/ldclient-rb/impl/model/serialization.rb +++ b/lib/ldclient-rb/impl/model/serialization.rb @@ -1,13 +1,14 @@ +require "ldclient-rb/impl/model/preprocessed_data" module LaunchDarkly module Impl module Model # Abstraction of deserializing a feature flag or segment that was read from a data store or # received from LaunchDarkly. - def self.deserialize(kind, json) + def self.deserialize(kind, json, logger = nil) return nil if json.nil? item = JSON.parse(json, symbolize_names: true) - postprocess_item_after_deserializing!(kind, item) + DataModelPreprocessing::Preprocessor.new(logger).preprocess_item!(kind, item) item end @@ -18,45 +19,14 @@ def self.serialize(kind, item) end # Translates a { flags: ..., segments: ... } object received from LaunchDarkly to the data store format. - def self.make_all_store_data(received_data) + def self.make_all_store_data(received_data, logger = nil) + preprocessor = DataModelPreprocessing::Preprocessor.new(logger) flags = received_data[:flags] - postprocess_items_after_deserializing!(FEATURES, flags) + preprocessor.preprocess_all_items!(FEATURES, flags) segments = received_data[:segments] - postprocess_items_after_deserializing!(SEGMENTS, segments) + preprocessor.preprocess_all_items!(SEGMENTS, segments) { FEATURES => flags, SEGMENTS => segments } end - - # Called after we have deserialized a model item from JSON (because we received it from LaunchDarkly, - # or read it from a persistent data store). This allows us to precompute some derived attributes that - # will never change during the lifetime of that item. - def self.postprocess_item_after_deserializing!(kind, item) - return if !item - # Currently we are special-casing this for FEATURES; eventually it will be handled by delegating - # to the "kind" object or the item class. - if kind.eql? FEATURES - # For feature flags, we precompute all possible parameterized EvaluationReason instances. - prereqs = item[:prerequisites] - if !prereqs.nil? - prereqs.each do |prereq| - prereq[:_reason] = EvaluationReason::prerequisite_failed(prereq[:key]) - end - end - rules = item[:rules] - if !rules.nil? - rules.each_index do |i| - rule = rules[i] - rule[:_reason] = EvaluationReason::rule_match(i, rule[:id]) - end - end - end - end - - def self.postprocess_items_after_deserializing!(kind, items_map) - return items_map if !items_map - items_map.each do |key, item| - postprocess_item_after_deserializing!(kind, item) - end - end end end end diff --git a/lib/ldclient-rb/impl/util.rb b/lib/ldclient-rb/impl/util.rb index 5fe93a2b..165ce885 100644 --- a/lib/ldclient-rb/impl/util.rb +++ b/lib/ldclient-rb/impl/util.rb @@ -15,8 +15,66 @@ def self.default_http_headers(sdk_key, config) ret["X-LaunchDarkly-Wrapper"] = config.wrapper_name + (config.wrapper_version ? "/" + config.wrapper_version : "") end + + app_value = application_header_value config.application + ret["X-LaunchDarkly-Tags"] = app_value unless app_value.nil? || app_value.empty? + ret end + + # + # Generate an HTTP Header value containing the application meta information (@see #application). + # + # @return [String] + # + def self.application_header_value(application) + parts = [] + unless application[:id].empty? + parts << "application-id/#{application[:id]}" + end + + unless application[:version].empty? + parts << "application-version/#{application[:version]}" + end + + parts.join(" ") + end + + # + # @param value [String] + # @param name [Symbol] + # @param logger [Logger] + # @return [String] + # + def self.validate_application_value(value, name, logger) + value = value.to_s + + return "" if value.empty? + + if value.length > 64 + logger.warn { "Value of application[#{name}] was longer than 64 characters and was discarded" } + return "" + end + + if value.match(/[^a-zA-Z0-9._-]/) + logger.warn { "Value of application[#{name}] contained invalid characters and was discarded" } + return "" + end + + value + end + + # + # @param app [Hash] + # @param logger [Logger] + # @return [Hash] + # + def self.validate_application_info(app, logger) + { + id: validate_application_value(app[:id], :id, logger), + version: validate_application_value(app[:version], :version, logger), + } + end end end end diff --git a/lib/ldclient-rb/requestor.rb b/lib/ldclient-rb/requestor.rb index f13a63db..7d3c4cb9 100644 --- a/lib/ldclient-rb/requestor.rb +++ b/lib/ldclient-rb/requestor.rb @@ -31,7 +31,7 @@ def initialize(sdk_key, config) def request_all_data() all_data = JSON.parse(make_request("/sdk/latest-all"), symbolize_names: true) - Impl::Model.make_all_store_data(all_data) + Impl::Model.make_all_store_data(all_data, @config.logger) end def stop @@ -44,7 +44,7 @@ def stop private def request_single_item(kind, path) - Impl::Model.deserialize(kind, make_request(path)) + Impl::Model.deserialize(kind, make_request(path), @config.logger) end def make_request(path) diff --git a/lib/ldclient-rb/stream.rb b/lib/ldclient-rb/stream.rb index 211e6321..5ab3eea8 100644 --- a/lib/ldclient-rb/stream.rb +++ b/lib/ldclient-rb/stream.rb @@ -86,7 +86,7 @@ def process_message(message) @config.logger.debug { "[LDClient] Stream received #{method} message: #{message.data}" } if method == PUT message = JSON.parse(message.data, symbolize_names: true) - all_data = Impl::Model.make_all_store_data(message[:data]) + all_data = Impl::Model.make_all_store_data(message[:data], @config.logger) @feature_store.init(all_data) @initialized.make_true @config.logger.info { "[LDClient] Stream initialized" } @@ -97,7 +97,7 @@ def process_message(message) key = key_for_path(kind, data[:path]) if key data = data[:data] - Impl::Model.postprocess_item_after_deserializing!(kind, data) + Impl::DataModelPreprocessing::Preprocessor.new(@config.logger).preprocess_item!(kind, data) @feature_store.upsert(kind, data) break end diff --git a/spec/config_spec.rb b/spec/config_spec.rb index 30dcb8f8..2b66e8b9 100644 --- a/spec/config_spec.rb +++ b/spec/config_spec.rb @@ -60,4 +60,37 @@ expect(subject.new(poll_interval: 29).poll_interval).to eq 30 end end + + describe ".application" do + it "can be set and read" do + app = { id: "my-id", version: "abcdef" } + expect(subject.new(application: app).application).to eq app + end + + it "can handle non-string values" do + expect(subject.new(application: { id: 1, version: 2 }).application).to eq ({ id: "1", version: "2" }) + end + + it "will ignore invalid keys" do + expect(subject.new(application: { invalid: 1, hashKey: 2 }).application).to eq ({ id: "", version: "" }) + end + + it "will drop invalid values" do + [" ", "@", ":", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-a"]. each do |value| + expect(subject.new(logger: $null_log, application: { id: value, version: value }).application).to eq ({ id: "", version: "" }) + end + end + + it "will generate correct header tag value" do + [ + { :id => "id", :version => "version", :expected => "application-id/id application-version/version" }, + { :id => "id", :version => "", :expected => "application-id/id" }, + { :id => "", :version => "version", :expected => "application-version/version" }, + { :id => "", :version => "", :expected => "" } + ].each do |test_case| + config = subject.new(application: { id: test_case[:id], version: test_case[:version] }) + expect(LaunchDarkly::Impl::Util.application_header_value(config.application)).to eq test_case[:expected] + end + end + end end diff --git a/spec/event_sender_spec.rb b/spec/event_sender_spec.rb index 72d19197..2b7fe38b 100644 --- a/spec/event_sender_spec.rb +++ b/spec/event_sender_spec.rb @@ -18,7 +18,7 @@ def make_sender(server) end def make_sender_with_events_uri(events_uri) - subject.new(sdk_key, Config.new(events_uri: events_uri, logger: $null_log), nil, 0.1) + subject.new(sdk_key, Config.new(events_uri: events_uri, logger: $null_log, application: {id: "id", version: "version"}), nil, 0.1) end def with_sender_and_server @@ -44,6 +44,7 @@ def with_sender_and_server "content-type" => [ "application/json" ], "user-agent" => [ "RubyClient/" + LaunchDarkly::VERSION ], "x-launchdarkly-event-schema" => [ "3" ], + "x-launchdarkly-tags" => [ "application-id/id application-version/version" ], "connection" => [ "Keep-Alive" ] }) expect(req.header['x-launchdarkly-payload-id']).not_to eq [] @@ -125,7 +126,7 @@ def with_sender_and_server es = make_sender_with_events_uri(fake_target_uri) result = es.send_event_data(fake_data, "", false) - + expect(result.success).to be true ensure ENV["http_proxy"] = nil @@ -135,7 +136,7 @@ def with_sender_and_server expect(body).to eq fake_data end end - + [400, 408, 429, 500].each do |status| it "handles recoverable error #{status}" do with_sender_and_server do |es, server| diff --git a/spec/impl/evaluator_big_segments_spec.rb b/spec/impl/evaluator_big_segments_spec.rb index 32db7d79..36767567 100644 --- a/spec/impl/evaluator_big_segments_spec.rb +++ b/spec/impl/evaluator_big_segments_spec.rb @@ -5,155 +5,155 @@ module LaunchDarkly module Impl - describe "Evaluator (big segments)", :evaluator_spec_base => true do - subject { Evaluator } + evaluator_tests_with_and_without_preprocessing "Evaluator (big segments)" do |desc, factory| + describe "#{desc} - evaluate", :evaluator_spec_base => true do + it "segment is not matched if there is no way to query it" do + segment = factory.segment({ + key: 'test', + included: [ user[:key] ], # included should be ignored for a big segment + version: 1, + unbounded: true, + generation: 1 + }) + e = EvaluatorBuilder.new(logger). + with_segment(segment). + build + flag = factory.boolean_flag_with_clauses([make_segment_match_clause(segment)]) + result = e.evaluate(flag, user) + expect(result.detail.value).to be false + expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::NOT_CONFIGURED) + end - it "segment is not matched if there is no way to query it" do - segment = { - key: 'test', - included: [ user[:key] ], # included should be ignored for a big segment - version: 1, - unbounded: true, - generation: 1 - } - e = EvaluatorBuilder.new(logger). - with_segment(segment). - build - flag = boolean_flag_with_clauses([make_segment_match_clause(segment)]) - result = e.evaluate(flag, user) - expect(result.detail.value).to be false - expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::NOT_CONFIGURED) - end + it "segment with no generation is not matched" do + segment = factory.segment({ + key: 'test', + included: [ user[:key] ], # included should be ignored for a big segment + version: 1, + unbounded: true + }) + e = EvaluatorBuilder.new(logger). + with_segment(segment). + build + flag = factory.boolean_flag_with_clauses([make_segment_match_clause(segment)]) + result = e.evaluate(flag, user) + expect(result.detail.value).to be false + expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::NOT_CONFIGURED) + end - it "segment with no generation is not matched" do - segment = { - key: 'test', - included: [ user[:key] ], # included should be ignored for a big segment - version: 1, - unbounded: true - } - e = EvaluatorBuilder.new(logger). - with_segment(segment). - build - flag = boolean_flag_with_clauses([make_segment_match_clause(segment)]) - result = e.evaluate(flag, user) - expect(result.detail.value).to be false - expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::NOT_CONFIGURED) - end + it "matched with include" do + segment = factory.segment({ + key: 'test', + version: 1, + unbounded: true, + generation: 2 + }) + e = EvaluatorBuilder.new(logger). + with_segment(segment). + with_big_segment_for_user(user, segment, true). + build + flag = factory.boolean_flag_with_clauses([make_segment_match_clause(segment)]) + result = e.evaluate(flag, user) + expect(result.detail.value).to be true + expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY) + end - it "matched with include" do - segment = { - key: 'test', - version: 1, - unbounded: true, - generation: 2 - } - e = EvaluatorBuilder.new(logger). - with_segment(segment). - with_big_segment_for_user(user, segment, true). - build - flag = boolean_flag_with_clauses([make_segment_match_clause(segment)]) - result = e.evaluate(flag, user) - expect(result.detail.value).to be true - expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY) - end + it "matched with rule" do + segment = factory.segment({ + key: 'test', + version: 1, + unbounded: true, + generation: 2, + rules: [ + { clauses: [ make_user_matching_clause(user) ] } + ] + }) + e = EvaluatorBuilder.new(logger). + with_segment(segment). + with_big_segment_for_user(user, segment, nil). + build + flag = factory.boolean_flag_with_clauses([make_segment_match_clause(segment)]) + result = e.evaluate(flag, user) + expect(result.detail.value).to be true + expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY) + end - it "matched with rule" do - segment = { - key: 'test', - version: 1, - unbounded: true, - generation: 2, - rules: [ - { clauses: [ make_user_matching_clause(user) ] } - ] - } - e = EvaluatorBuilder.new(logger). - with_segment(segment). - with_big_segment_for_user(user, segment, nil). - build - flag = boolean_flag_with_clauses([make_segment_match_clause(segment)]) - result = e.evaluate(flag, user) - expect(result.detail.value).to be true - expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY) - end + it "unmatched by exclude regardless of rule" do + segment = factory.segment({ + key: 'test', + version: 1, + unbounded: true, + generation: 2, + rules: [ + { clauses: [ make_user_matching_clause(user) ] } + ] + }) + e = EvaluatorBuilder.new(logger). + with_segment(segment). + with_big_segment_for_user(user, segment, false). + build + flag = factory.boolean_flag_with_clauses([make_segment_match_clause(segment)]) + result = e.evaluate(flag, user) + expect(result.detail.value).to be false + expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY) + end - it "unmatched by exclude regardless of rule" do - segment = { - key: 'test', - version: 1, - unbounded: true, - generation: 2, - rules: [ - { clauses: [ make_user_matching_clause(user) ] } - ] - }; - e = EvaluatorBuilder.new(logger). - with_segment(segment). - with_big_segment_for_user(user, segment, false). - build - flag = boolean_flag_with_clauses([make_segment_match_clause(segment)]) - result = e.evaluate(flag, user) - expect(result.detail.value).to be false - expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY) - end - - it "status is returned from provider" do - segment = { - key: 'test', - version: 1, - unbounded: true, - generation: 2 - } - e = EvaluatorBuilder.new(logger). - with_segment(segment). - with_big_segment_for_user(user, segment, true). - with_big_segments_status(BigSegmentsStatus::STALE). - build - flag = boolean_flag_with_clauses([make_segment_match_clause(segment)]) - result = e.evaluate(flag, user) - expect(result.detail.value).to be true - expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::STALE) - end + it "status is returned from provider" do + segment = factory.segment({ + key: 'test', + version: 1, + unbounded: true, + generation: 2 + }) + e = EvaluatorBuilder.new(logger). + with_segment(segment). + with_big_segment_for_user(user, segment, true). + with_big_segments_status(BigSegmentsStatus::STALE). + build + flag = factory.boolean_flag_with_clauses([make_segment_match_clause(segment)]) + result = e.evaluate(flag, user) + expect(result.detail.value).to be true + expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::STALE) + end - it "queries state only once per user even if flag references multiple segments" do - segment1 = { - key: 'segmentkey1', - version: 1, - unbounded: true, - generation: 2 - } - segment2 = { - key: 'segmentkey2', - version: 1, - unbounded: true, - generation: 3 - } - flag = { - key: 'key', - on: true, - fallthrough: { variation: 0 }, - variations: [ false, true ], - rules: [ - { variation: 1, clauses: [ make_segment_match_clause(segment1) ]}, - { variation: 1, clauses: [ make_segment_match_clause(segment2) ]} - ] - } - - queries = [] - e = EvaluatorBuilder.new(logger). - with_segment(segment1).with_segment(segment2). - with_big_segment_for_user(user, segment2, true). - record_big_segments_queries(queries). - build - # The membership deliberately does not include segment1, because we want the first rule to be - # a non-match so that it will continue on and check segment2 as well. - - result = e.evaluate(flag, user) - expect(result.detail.value).to be true - expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY) + it "queries state only once per user even if flag references multiple segments" do + segment1 = factory.segment({ + key: 'segmentkey1', + version: 1, + unbounded: true, + generation: 2 + }) + segment2 = factory.segment({ + key: 'segmentkey2', + version: 1, + unbounded: true, + generation: 3 + }) + flag = factory.flag({ + key: 'key', + on: true, + fallthrough: { variation: 0 }, + variations: [ false, true ], + rules: [ + { variation: 1, clauses: [ make_segment_match_clause(segment1) ]}, + { variation: 1, clauses: [ make_segment_match_clause(segment2) ]} + ] + }) + + queries = [] + e = EvaluatorBuilder.new(logger). + with_segment(segment1).with_segment(segment2). + with_big_segment_for_user(user, segment2, true). + record_big_segments_queries(queries). + build + # The membership deliberately does not include segment1, because we want the first rule to be + # a non-match so that it will continue on and check segment2 as well. + + result = e.evaluate(flag, user) + expect(result.detail.value).to be true + expect(result.detail.reason.big_segments_status).to be(BigSegmentsStatus::HEALTHY) - expect(queries).to eq([ user[:key] ]) + expect(queries).to eq([ user[:key] ]) + end end end end diff --git a/spec/impl/evaluator_clause_spec.rb b/spec/impl/evaluator_clause_spec.rb index 2b76505d..facf68de 100644 --- a/spec/impl/evaluator_clause_spec.rb +++ b/spec/impl/evaluator_clause_spec.rb @@ -3,52 +3,52 @@ module LaunchDarkly module Impl - describe "Evaluator (clauses)", :evaluator_spec_base => true do - subject { Evaluator } + evaluator_tests_with_and_without_preprocessing "Evaluator (clauses)" do |desc, factory| + describe "#{desc} - evaluate", :evaluator_spec_base => true do + it "can match built-in attribute" do + user = { key: 'x', name: 'Bob' } + clause = { attribute: 'name', op: 'in', values: ['Bob'] } + flag = factory.boolean_flag_with_clauses([clause]) + expect(basic_evaluator.evaluate(flag, user).detail.value).to be true + end - it "can match built-in attribute" do - user = { key: 'x', name: 'Bob' } - clause = { attribute: 'name', op: 'in', values: ['Bob'] } - flag = boolean_flag_with_clauses([clause]) - expect(basic_evaluator.evaluate(flag, user).detail.value).to be true - end - - it "can match custom attribute" do - user = { key: 'x', name: 'Bob', custom: { legs: 4 } } - clause = { attribute: 'legs', op: 'in', values: [4] } - flag = boolean_flag_with_clauses([clause]) - expect(basic_evaluator.evaluate(flag, user).detail.value).to be true - end + it "can match custom attribute" do + user = { key: 'x', name: 'Bob', custom: { legs: 4 } } + clause = { attribute: 'legs', op: 'in', values: [4] } + flag = factory.boolean_flag_with_clauses([clause]) + expect(basic_evaluator.evaluate(flag, user).detail.value).to be true + end - it "returns false for missing attribute" do - user = { key: 'x', name: 'Bob' } - clause = { attribute: 'legs', op: 'in', values: [4] } - flag = boolean_flag_with_clauses([clause]) - expect(basic_evaluator.evaluate(flag, user).detail.value).to be false - end + it "returns false for missing attribute" do + user = { key: 'x', name: 'Bob' } + clause = { attribute: 'legs', op: 'in', values: [4] } + flag = factory.boolean_flag_with_clauses([clause]) + expect(basic_evaluator.evaluate(flag, user).detail.value).to be false + end - it "returns false for unknown operator" do - user = { key: 'x', name: 'Bob' } - clause = { attribute: 'name', op: 'unknown', values: [4] } - flag = boolean_flag_with_clauses([clause]) - expect(basic_evaluator.evaluate(flag, user).detail.value).to be false - end + it "returns false for unknown operator" do + user = { key: 'x', name: 'Bob' } + clause = { attribute: 'name', op: 'unknown', values: [4] } + flag = factory.boolean_flag_with_clauses([clause]) + expect(basic_evaluator.evaluate(flag, user).detail.value).to be false + end - it "does not stop evaluating rules after clause with unknown operator" do - user = { key: 'x', name: 'Bob' } - clause0 = { attribute: 'name', op: 'unknown', values: [4] } - rule0 = { clauses: [ clause0 ], variation: 1 } - clause1 = { attribute: 'name', op: 'in', values: ['Bob'] } - rule1 = { clauses: [ clause1 ], variation: 1 } - flag = boolean_flag_with_rules([rule0, rule1]) - expect(basic_evaluator.evaluate(flag, user).detail.value).to be true - end + it "does not stop evaluating rules after clause with unknown operator" do + user = { key: 'x', name: 'Bob' } + clause0 = { attribute: 'name', op: 'unknown', values: [4] } + rule0 = { clauses: [ clause0 ], variation: 1 } + clause1 = { attribute: 'name', op: 'in', values: ['Bob'] } + rule1 = { clauses: [ clause1 ], variation: 1 } + flag = factory.boolean_flag_with_rules([rule0, rule1]) + expect(basic_evaluator.evaluate(flag, user).detail.value).to be true + end - it "can be negated" do - user = { key: 'x', name: 'Bob' } - clause = { attribute: 'name', op: 'in', values: ['Bob'], negate: true } - flag = boolean_flag_with_clauses([clause]) - expect(basic_evaluator.evaluate(flag, user).detail.value).to be false + it "can be negated" do + user = { key: 'x', name: 'Bob' } + clause = { attribute: 'name', op: 'in', values: ['Bob'], negate: true } + flag = factory.boolean_flag_with_clauses([clause]) + expect(basic_evaluator.evaluate(flag, user).detail.value).to be false + end end end end diff --git a/spec/impl/evaluator_rule_spec.rb b/spec/impl/evaluator_rule_spec.rb index 6a6b9310..68e724cd 100644 --- a/spec/impl/evaluator_rule_spec.rb +++ b/spec/impl/evaluator_rule_spec.rb @@ -3,124 +3,150 @@ module LaunchDarkly module Impl - describe "Evaluator (rules)", :evaluator_spec_base => true do - subject { Evaluator } - - it "matches user from rules" do - rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: 1 } - flag = boolean_flag_with_rules([rule]) - user = { key: 'userkey' } - detail = EvaluationDetail.new(true, 1, EvaluationReason::rule_match(0, 'ruleid')) - result = basic_evaluator.evaluate(flag, user) - expect(result.detail).to eq(detail) - expect(result.prereq_evals).to eq(nil) - end - - it "reuses rule match reason instances if possible" do - rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: 1 } - flag = boolean_flag_with_rules([rule]) - Model.postprocess_item_after_deserializing!(FEATURES, flag) # now there's a cached rule match reason - user = { key: 'userkey' } - detail = EvaluationDetail.new(true, 1, EvaluationReason::rule_match(0, 'ruleid')) - result1 = basic_evaluator.evaluate(flag, user) - result2 = basic_evaluator.evaluate(flag, user) - expect(result1.detail.reason.rule_id).to eq 'ruleid' - expect(result1.detail.reason).to be result2.detail.reason - end - - it "returns an error if rule variation is too high" do - rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: 999 } - flag = boolean_flag_with_rules([rule]) - user = { key: 'userkey' } - detail = EvaluationDetail.new(nil, nil, - EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) - result = basic_evaluator.evaluate(flag, user) - expect(result.detail).to eq(detail) - expect(result.prereq_evals).to eq(nil) - end - - it "returns an error if rule variation is negative" do - rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: -1 } - flag = boolean_flag_with_rules([rule]) - user = { key: 'userkey' } - detail = EvaluationDetail.new(nil, nil, - EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) - result = basic_evaluator.evaluate(flag, user) - expect(result.detail).to eq(detail) - expect(result.prereq_evals).to eq(nil) - end + evaluator_tests_with_and_without_preprocessing "Evaluator (rules)" do |desc, factory| + describe "#{desc} - evaluate", :evaluator_spec_base => true do + it "matches user from rules" do + rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: 1 } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: 'userkey' } + detail = EvaluationDetail.new(true, 1, EvaluationReason::rule_match(0, 'ruleid')) + result = basic_evaluator.evaluate(flag, user) + expect(result.detail).to eq(detail) + expect(result.prereq_evals).to eq(nil) + end - it "returns an error if rule has neither variation nor rollout" do - rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }] } - flag = boolean_flag_with_rules([rule]) - user = { key: 'userkey' } - detail = EvaluationDetail.new(nil, nil, - EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) - result = basic_evaluator.evaluate(flag, user) - expect(result.detail).to eq(detail) - expect(result.prereq_evals).to eq(nil) - end + if factory.with_preprocessing + it "reuses rule match result detail instances" do + rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: 1 } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: 'userkey' } + detail = EvaluationDetail.new(true, 1, EvaluationReason::rule_match(0, 'ruleid')) + result1 = basic_evaluator.evaluate(flag, user) + result2 = basic_evaluator.evaluate(flag, user) + expect(result1.detail.reason.rule_id).to eq 'ruleid' + expect(result1.detail).to be result2.detail + end + end - it "returns an error if rule has a rollout with no variations" do - rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], - rollout: { variations: [] } } - flag = boolean_flag_with_rules([rule]) - user = { key: 'userkey' } - detail = EvaluationDetail.new(nil, nil, - EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) - result = basic_evaluator.evaluate(flag, user) - expect(result.detail).to eq(detail) - expect(result.prereq_evals).to eq(nil) - end + it "returns an error if rule variation is too high" do + rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: 999 } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: 'userkey' } + detail = EvaluationDetail.new(nil, nil, + EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) + result = basic_evaluator.evaluate(flag, user) + expect(result.detail).to eq(detail) + expect(result.prereq_evals).to eq(nil) + end - it "coerces user key to a string for evaluation" do - clause = { attribute: 'key', op: 'in', values: ['999'] } - flag = boolean_flag_with_clauses([clause]) - user = { key: 999 } - result = basic_evaluator.evaluate(flag, user) - expect(result.detail.value).to eq(true) - end + it "returns an error if rule variation is negative" do + rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: -1 } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: 'userkey' } + detail = EvaluationDetail.new(nil, nil, + EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) + result = basic_evaluator.evaluate(flag, user) + expect(result.detail).to eq(detail) + expect(result.prereq_evals).to eq(nil) + end - it "coerces secondary key to a string for evaluation" do - # We can't really verify that the rollout calculation works correctly, but we can at least - # make sure it doesn't error out if there's a non-string secondary value (ch35189) - rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], - rollout: { salt: '', variations: [ { weight: 100000, variation: 1 } ] } } - flag = boolean_flag_with_rules([rule]) - user = { key: "userkey", secondary: 999 } - result = basic_evaluator.evaluate(flag, user) - expect(result.detail.reason).to eq(EvaluationReason::rule_match(0, 'ruleid')) - end + it "returns an error if rule has neither variation nor rollout" do + rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }] } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: 'userkey' } + detail = EvaluationDetail.new(nil, nil, + EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) + result = basic_evaluator.evaluate(flag, user) + expect(result.detail).to eq(detail) + expect(result.prereq_evals).to eq(nil) + end - describe "experiment rollout behavior" do - it "sets the in_experiment value if rollout kind is experiment " do + it "returns an error if rule has a rollout with no variations" do rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], - rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: false } ] } } - flag = boolean_flag_with_rules([rule]) - user = { key: "userkey", secondary: 999 } + rollout: { variations: [] } } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: 'userkey' } + detail = EvaluationDetail.new(nil, nil, + EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) result = basic_evaluator.evaluate(flag, user) - expect(result.detail.reason.to_json).to include('"inExperiment":true') - expect(result.detail.reason.in_experiment).to eq(true) + expect(result.detail).to eq(detail) + expect(result.prereq_evals).to eq(nil) end - it "does not set the in_experiment value if rollout kind is not experiment " do - rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], - rollout: { kind: 'rollout', variations: [ { weight: 100000, variation: 1, untracked: false } ] } } - flag = boolean_flag_with_rules([rule]) - user = { key: "userkey", secondary: 999 } + it "coerces user key to a string for evaluation" do + clause = { attribute: 'key', op: 'in', values: ['999'] } + flag = factory.boolean_flag_with_clauses([clause]) + user = { key: 999 } result = basic_evaluator.evaluate(flag, user) - expect(result.detail.reason.to_json).to_not include('"inExperiment":true') - expect(result.detail.reason.in_experiment).to eq(nil) + expect(result.detail.value).to eq(true) end - it "does not set the in_experiment value if rollout kind is experiment and untracked is true" do + it "coerces secondary key to a string for evaluation" do + # We can't really verify that the rollout calculation works correctly, but we can at least + # make sure it doesn't error out if there's a non-string secondary value (ch35189) rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], - rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: true } ] } } - flag = boolean_flag_with_rules([rule]) + rollout: { salt: '', variations: [ { weight: 100000, variation: 1 } ] } } + flag = factory.boolean_flag_with_rules([rule]) user = { key: "userkey", secondary: 999 } result = basic_evaluator.evaluate(flag, user) - expect(result.detail.reason.to_json).to_not include('"inExperiment":true') - expect(result.detail.reason.in_experiment).to eq(nil) + expect(result.detail.reason).to eq(EvaluationReason::rule_match(0, 'ruleid')) + end + + describe "rule experiment/rollout behavior" do + it "evaluates rollout for rule" do + rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], + rollout: { variations: [ { weight: 100000, variation: 1, untracked: false } ] } } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: 'userkey' } + detail = EvaluationDetail.new(true, 1, EvaluationReason::rule_match(0, 'ruleid')) + result = basic_evaluator.evaluate(flag, user) + expect(result.detail).to eq(detail) + expect(result.prereq_evals).to eq(nil) + end + + if factory.with_preprocessing + it "reuses rule rollout result detail instance" do + rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], + rollout: { variations: [ { weight: 100000, variation: 1, untracked: false } ] } } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: 'userkey' } + detail = EvaluationDetail.new(true, 1, EvaluationReason::rule_match(0, 'ruleid')) + result1 = basic_evaluator.evaluate(flag, user) + result2 = basic_evaluator.evaluate(flag, user) + expect(result1.detail).to eq(detail) + expect(result2.detail).to be(result1.detail) + end + end + + it "sets the in_experiment value if rollout kind is experiment " do + rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], + rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: false } ] } } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: "userkey", secondary: 999 } + result = basic_evaluator.evaluate(flag, user) + expect(result.detail.reason.to_json).to include('"inExperiment":true') + expect(result.detail.reason.in_experiment).to eq(true) + end + + it "does not set the in_experiment value if rollout kind is not experiment " do + rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], + rollout: { kind: 'rollout', variations: [ { weight: 100000, variation: 1, untracked: false } ] } } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: "userkey", secondary: 999 } + result = basic_evaluator.evaluate(flag, user) + expect(result.detail.reason.to_json).to_not include('"inExperiment":true') + expect(result.detail.reason.in_experiment).to eq(nil) + end + + it "does not set the in_experiment value if rollout kind is experiment and untracked is true" do + rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], + rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: true } ] } } + flag = factory.boolean_flag_with_rules([rule]) + user = { key: "userkey", secondary: 999 } + result = basic_evaluator.evaluate(flag, user) + expect(result.detail.reason.to_json).to_not include('"inExperiment":true') + expect(result.detail.reason.in_experiment).to eq(nil) + end end end end diff --git a/spec/impl/evaluator_segment_spec.rb b/spec/impl/evaluator_segment_spec.rb index bb526b7c..70d86546 100644 --- a/spec/impl/evaluator_segment_spec.rb +++ b/spec/impl/evaluator_segment_spec.rb @@ -3,118 +3,118 @@ module LaunchDarkly module Impl - describe "Evaluator (segments)", :evaluator_spec_base => true do - subject { Evaluator } + evaluator_tests_with_and_without_preprocessing "Evaluator (segments)" do |desc, factory| + describe "#{desc} - evaluate", :evaluator_spec_base => true do + def test_segment_match(factory, segment) + clause = make_segment_match_clause(segment) + flag = factory.boolean_flag_with_clauses([clause]) + e = EvaluatorBuilder.new(logger).with_segment(segment).build + e.evaluate(flag, user).detail.value + end - def test_segment_match(segment) - clause = make_segment_match_clause(segment) - flag = boolean_flag_with_clauses([clause]) - e = EvaluatorBuilder.new(logger).with_segment(segment).build - e.evaluate(flag, user).detail.value - end - - it "retrieves segment from segment store for segmentMatch operator" do - segment = { - key: 'segkey', - included: [ 'userkey' ], - version: 1, - deleted: false - } - e = EvaluatorBuilder.new(logger).with_segment(segment).build - flag = boolean_flag_with_clauses([make_segment_match_clause(segment)]) - expect(e.evaluate(flag, user).detail.value).to be true - end + it "retrieves segment from segment store for segmentMatch operator" do + segment = { + key: 'segkey', + included: [ 'userkey' ], + version: 1, + deleted: false + } + e = EvaluatorBuilder.new(logger).with_segment(segment).build + flag = factory.boolean_flag_with_clauses([make_segment_match_clause(segment)]) + expect(e.evaluate(flag, user).detail.value).to be true + end - it "falls through with no errors if referenced segment is not found" do - e = EvaluatorBuilder.new(logger).with_unknown_segment('segkey').build - clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] } - flag = boolean_flag_with_clauses([clause]) - expect(e.evaluate(flag, user).detail.value).to be false - end + it "falls through with no errors if referenced segment is not found" do + e = EvaluatorBuilder.new(logger).with_unknown_segment('segkey').build + clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] } + flag = factory.boolean_flag_with_clauses([clause]) + expect(e.evaluate(flag, user).detail.value).to be false + end - it 'explicitly includes user' do - segment = make_segment('segkey') - segment[:included] = [ user[:key] ] - expect(test_segment_match(segment)).to be true - end + it 'explicitly includes user' do + segment = make_segment('segkey') + segment[:included] = [ user[:key] ] + expect(test_segment_match(factory, segment)).to be true + end - it 'explicitly excludes user' do - segment = make_segment('segkey') - segment[:excluded] = [ user[:key] ] - expect(test_segment_match(segment)).to be false - end + it 'explicitly excludes user' do + segment = make_segment('segkey') + segment[:excluded] = [ user[:key] ] + expect(test_segment_match(factory, segment)).to be false + end - it 'both includes and excludes user; include takes priority' do - segment = make_segment('segkey') - segment[:included] = [ user[:key] ] - segment[:excluded] = [ user[:key] ] - expect(test_segment_match(segment)).to be true - end + it 'both includes and excludes user; include takes priority' do + segment = make_segment('segkey') + segment[:included] = [ user[:key] ] + segment[:excluded] = [ user[:key] ] + expect(test_segment_match(factory, segment)).to be true + end - it 'matches user by rule when weight is absent' do - segClause = make_user_matching_clause(user, :email) - segRule = { - clauses: [ segClause ] - } - segment = make_segment('segkey') - segment[:rules] = [ segRule ] - expect(test_segment_match(segment)).to be true - end + it 'matches user by rule when weight is absent' do + segClause = make_user_matching_clause(user, :email) + segRule = { + clauses: [ segClause ] + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + expect(test_segment_match(factory, segment)).to be true + end - it 'matches user by rule when weight is nil' do - segClause = make_user_matching_clause(user, :email) - segRule = { - clauses: [ segClause ], - weight: nil - } - segment = make_segment('segkey') - segment[:rules] = [ segRule ] - expect(test_segment_match(segment)).to be true - end + it 'matches user by rule when weight is nil' do + segClause = make_user_matching_clause(user, :email) + segRule = { + clauses: [ segClause ], + weight: nil + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + expect(test_segment_match(factory, segment)).to be true + end - it 'matches user with full rollout' do - segClause = make_user_matching_clause(user, :email) - segRule = { - clauses: [ segClause ], - weight: 100000 - } - segment = make_segment('segkey') - segment[:rules] = [ segRule ] - expect(test_segment_match(segment)).to be true - end + it 'matches user with full rollout' do + segClause = make_user_matching_clause(user, :email) + segRule = { + clauses: [ segClause ], + weight: 100000 + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + expect(test_segment_match(factory, segment)).to be true + end - it "doesn't match user with zero rollout" do - segClause = make_user_matching_clause(user, :email) - segRule = { - clauses: [ segClause ], - weight: 0 - } - segment = make_segment('segkey') - segment[:rules] = [ segRule ] - expect(test_segment_match(segment)).to be false - end + it "doesn't match user with zero rollout" do + segClause = make_user_matching_clause(user, :email) + segRule = { + clauses: [ segClause ], + weight: 0 + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + expect(test_segment_match(factory, segment)).to be false + end - it "matches user with multiple clauses" do - segClause1 = make_user_matching_clause(user, :email) - segClause2 = make_user_matching_clause(user, :name) - segRule = { - clauses: [ segClause1, segClause2 ] - } - segment = make_segment('segkey') - segment[:rules] = [ segRule ] - expect(test_segment_match(segment)).to be true - end + it "matches user with multiple clauses" do + segClause1 = make_user_matching_clause(user, :email) + segClause2 = make_user_matching_clause(user, :name) + segRule = { + clauses: [ segClause1, segClause2 ] + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + expect(test_segment_match(factory, segment)).to be true + end - it "doesn't match user with multiple clauses if a clause doesn't match" do - segClause1 = make_user_matching_clause(user, :email) - segClause2 = make_user_matching_clause(user, :name) - segClause2[:values] = [ 'wrong' ] - segRule = { - clauses: [ segClause1, segClause2 ] - } - segment = make_segment('segkey') - segment[:rules] = [ segRule ] - expect(test_segment_match(segment)).to be false + it "doesn't match user with multiple clauses if a clause doesn't match" do + segClause1 = make_user_matching_clause(user, :email) + segClause2 = make_user_matching_clause(user, :name) + segClause2[:values] = [ 'wrong' ] + segRule = { + clauses: [ segClause1, segClause2 ] + } + segment = make_segment('segkey') + segment[:rules] = [ segRule ] + expect(test_segment_match(factory, segment)).to be false + end end end end diff --git a/spec/impl/evaluator_spec.rb b/spec/impl/evaluator_spec.rb index 20b231fb..7ac31728 100644 --- a/spec/impl/evaluator_spec.rb +++ b/spec/impl/evaluator_spec.rb @@ -1,21 +1,20 @@ require "events_test_util" +require "model_builders" require "spec_helper" require "impl/evaluator_spec_base" module LaunchDarkly module Impl - describe "Evaluator (general)", :evaluator_spec_base => true do - subject { Evaluator } - - describe "evaluate" do + evaluator_tests_with_and_without_preprocessing "Evaluator (general)" do |desc, factory| + describe "#{desc} - evaluate", :evaluator_spec_base => true do it "returns off variation if flag is off" do - flag = { + flag = factory.flag({ key: 'feature', on: false, offVariation: 1, fallthrough: { variation: 0 }, variations: ['a', 'b', 'c'] - } + }) user = { key: 'x' } detail = EvaluationDetail.new('b', 1, EvaluationReason::off) result = basic_evaluator.evaluate(flag, user) @@ -24,12 +23,12 @@ module Impl end it "returns nil if flag is off and off variation is unspecified" do - flag = { + flag = factory.flag({ key: 'feature', on: false, fallthrough: { variation: 0 }, variations: ['a', 'b', 'c'] - } + }) user = { key: 'x' } detail = EvaluationDetail.new(nil, nil, EvaluationReason::off) result = basic_evaluator.evaluate(flag, user) @@ -37,14 +36,32 @@ module Impl expect(result.prereq_evals).to eq(nil) end + if factory.with_preprocessing + it "reuses off result detail instance" do + flag = factory.flag({ + key: 'feature', + on: false, + offVariation: 1, + fallthrough: { variation: 0 }, + variations: ['a', 'b', 'c'] + }) + user = { key: 'x' } + detail = EvaluationDetail.new('b', 1, EvaluationReason::off) + result1 = basic_evaluator.evaluate(flag, user) + result2 = basic_evaluator.evaluate(flag, user) + expect(result1.detail).to eq(detail) + expect(result2.detail).to be(result1.detail) + end + end + it "returns an error if off variation is too high" do - flag = { + flag = factory.flag({ key: 'feature', on: false, offVariation: 999, fallthrough: { variation: 0 }, variations: ['a', 'b', 'c'] - } + }) user = { key: 'x' } detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) @@ -54,13 +71,13 @@ module Impl end it "returns an error if off variation is negative" do - flag = { + flag = factory.flag({ key: 'feature', on: false, offVariation: -1, fallthrough: { variation: 0 }, variations: ['a', 'b', 'c'] - } + }) user = { key: 'x' } detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) @@ -70,14 +87,14 @@ module Impl end it "returns off variation if prerequisite is not found" do - flag = { + flag = factory.flag({ key: 'feature0', on: true, prerequisites: [{key: 'badfeature', variation: 1}], fallthrough: { variation: 0 }, offVariation: 1, variations: ['a', 'b', 'c'] - } + }) user = { key: 'x' } detail = EvaluationDetail.new('b', 1, EvaluationReason::prerequisite_failed('badfeature')) e = EvaluatorBuilder.new(logger).with_unknown_flag('badfeature').build @@ -86,26 +103,27 @@ module Impl expect(result.prereq_evals).to eq(nil) end - it "reuses prerequisite-failed reason instances if possible" do - flag = { - key: 'feature0', - on: true, - prerequisites: [{key: 'badfeature', variation: 1}], - fallthrough: { variation: 0 }, - offVariation: 1, - variations: ['a', 'b', 'c'] - } - Model.postprocess_item_after_deserializing!(FEATURES, flag) # now there's a cached reason - user = { key: 'x' } - e = EvaluatorBuilder.new(logger).with_unknown_flag('badfeature').build - result1 = e.evaluate(flag, user) - expect(result1.detail.reason).to eq EvaluationReason::prerequisite_failed('badfeature') - result2 = e.evaluate(flag, user) - expect(result2.detail.reason).to be result1.detail.reason + if factory.with_preprocessing + it "reuses prerequisite-failed result detail instances" do + flag = factory.flag({ + key: 'feature0', + on: true, + prerequisites: [{key: 'badfeature', variation: 1}], + fallthrough: { variation: 0 }, + offVariation: 1, + variations: ['a', 'b', 'c'] + }) + user = { key: 'x' } + e = EvaluatorBuilder.new(logger).with_unknown_flag('badfeature').build + result1 = e.evaluate(flag, user) + expect(result1.detail.reason).to eq EvaluationReason::prerequisite_failed('badfeature') + result2 = e.evaluate(flag, user) + expect(result2.detail).to be result1.detail + end end it "returns off variation and event if prerequisite of a prerequisite is not found" do - flag = { + flag = factory.flag({ key: 'feature0', on: true, prerequisites: [{key: 'feature1', variation: 1}], @@ -113,15 +131,15 @@ module Impl offVariation: 1, variations: ['a', 'b', 'c'], version: 1 - } - flag1 = { + }) + flag1 = factory.flag({ key: 'feature1', on: true, prerequisites: [{key: 'feature2', variation: 1}], # feature2 doesn't exist fallthrough: { variation: 0 }, variations: ['d', 'e'], version: 2 - } + }) user = { key: 'x' } detail = EvaluationDetail.new('b', 1, EvaluationReason::prerequisite_failed('feature1')) expected_prereqs = [ @@ -134,7 +152,7 @@ module Impl end it "returns off variation and event if prerequisite is off" do - flag = { + flag = factory.flag({ key: 'feature0', on: true, prerequisites: [{key: 'feature1', variation: 1}], @@ -142,8 +160,8 @@ module Impl offVariation: 1, variations: ['a', 'b', 'c'], version: 1 - } - flag1 = { + }) + flag1 = factory.flag({ key: 'feature1', on: false, # note that even though it returns the desired variation, it is still off and therefore not a match @@ -151,7 +169,7 @@ module Impl fallthrough: { variation: 0 }, variations: ['d', 'e'], version: 2 - } + }) user = { key: 'x' } detail = EvaluationDetail.new('b', 1, EvaluationReason::prerequisite_failed('feature1')) expected_prereqs = [ @@ -164,7 +182,7 @@ module Impl end it "returns off variation and event if prerequisite is not met" do - flag = { + flag = factory.flag({ key: 'feature0', on: true, prerequisites: [{key: 'feature1', variation: 1}], @@ -172,14 +190,14 @@ module Impl offVariation: 1, variations: ['a', 'b', 'c'], version: 1 - } - flag1 = { + }) + flag1 = factory.flag({ key: 'feature1', on: true, fallthrough: { variation: 0 }, variations: ['d', 'e'], version: 2 - } + }) user = { key: 'x' } detail = EvaluationDetail.new('b', 1, EvaluationReason::prerequisite_failed('feature1')) expected_prereqs = [ @@ -192,7 +210,7 @@ module Impl end it "returns fallthrough variation and event if prerequisite is met and there are no rules" do - flag = { + flag = factory.flag({ key: 'feature0', on: true, prerequisites: [{key: 'feature1', variation: 1}], @@ -200,14 +218,14 @@ module Impl offVariation: 1, variations: ['a', 'b', 'c'], version: 1 - } - flag1 = { + }) + flag1 = factory.flag({ key: 'feature1', on: true, fallthrough: { variation: 1 }, variations: ['d', 'e'], version: 2 - } + }) user = { key: 'x' } detail = EvaluationDetail.new('a', 0, EvaluationReason::fallthrough) expected_prereqs = [ @@ -219,14 +237,55 @@ module Impl expect(result.prereq_evals).to eq(expected_prereqs) end + it "returns fallthrough variation if flag is on and no rules match" do + flag = factory.flag({ + key: 'feature0', + on: true, + fallthrough: { variation: 0 }, + offVariation: 1, + variations: ['a', 'b', 'c'], + version: 1, + rules: [ + { variation: 2, clauses: [ { attribute: "key", op: "in", values: ["zzz"] } ] } + ] + }) + user = { key: 'x' } + detail = EvaluationDetail.new('a', 0, EvaluationReason::fallthrough) + result = basic_evaluator.evaluate(flag, user) + expect(result.detail).to eq(detail) + expect(result.prereq_evals).to eq(nil) + end + + if factory.with_preprocessing + it "reuses fallthrough variation result detail instance" do + flag = factory.flag({ + key: 'feature0', + on: true, + fallthrough: { variation: 0 }, + offVariation: 1, + variations: ['a', 'b', 'c'], + version: 1, + rules: [ + { variation: 2, clauses: [ { attribute: "key", op: "in", values: ["zzz"] } ] } + ] + }) + user = { key: 'x' } + detail = EvaluationDetail.new('a', 0, EvaluationReason::fallthrough) + result1 = basic_evaluator.evaluate(flag, user) + result2 = basic_evaluator.evaluate(flag, user) + expect(result1.detail).to eq(detail) + expect(result2.detail).to be(result1.detail) + end + end + it "returns an error if fallthrough variation is too high" do - flag = { + flag = factory.flag({ key: 'feature', on: true, fallthrough: { variation: 999 }, offVariation: 1, variations: ['a', 'b', 'c'] - } + }) user = { key: 'userkey' } detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) result = basic_evaluator.evaluate(flag, user) @@ -235,13 +294,13 @@ module Impl end it "returns an error if fallthrough variation is negative" do - flag = { + flag = factory.flag({ key: 'feature', on: true, fallthrough: { variation: -1 }, offVariation: 1, variations: ['a', 'b', 'c'] - } + }) user = { key: 'userkey' } detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) result = basic_evaluator.evaluate(flag, user) @@ -250,13 +309,13 @@ module Impl end it "returns an error if fallthrough has no variation or rollout" do - flag = { + flag = factory.flag({ key: 'feature', on: true, fallthrough: { }, offVariation: 1, variations: ['a', 'b', 'c'] - } + }) user = { key: 'userkey' } detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) result = basic_evaluator.evaluate(flag, user) @@ -265,13 +324,13 @@ module Impl end it "returns an error if fallthrough has a rollout with no variations" do - flag = { + flag = factory.flag({ key: 'feature', on: true, fallthrough: { rollout: { variations: [] } }, offVariation: 1, variations: ['a', 'b', 'c'] - } + }) user = { key: 'userkey' } detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) result = basic_evaluator.evaluate(flag, user) @@ -280,7 +339,7 @@ module Impl end it "matches user from targets" do - flag = { + flag = factory.flag({ key: 'feature', on: true, targets: [ @@ -289,7 +348,7 @@ module Impl fallthrough: { variation: 0 }, offVariation: 1, variations: ['a', 'b', 'c'] - } + }) user = { key: 'userkey' } detail = EvaluationDetail.new('c', 2, EvaluationReason::target_match) result = basic_evaluator.evaluate(flag, user) @@ -297,15 +356,71 @@ module Impl expect(result.prereq_evals).to eq(nil) end - describe "experiment rollout behavior" do + if factory.with_preprocessing + it "reuses target-match result detail instances" do + flag = factory.flag({ + key: 'feature', + on: true, + targets: [ + { values: [ 'whoever', 'userkey' ], variation: 2 } + ], + fallthrough: { variation: 0 }, + offVariation: 1, + variations: ['a', 'b', 'c'] + }) + user = { key: 'userkey' } + detail = EvaluationDetail.new('c', 2, EvaluationReason::target_match) + result1 = basic_evaluator.evaluate(flag, user) + result2 = basic_evaluator.evaluate(flag, user) + expect(result1.detail).to eq(detail) + expect(result2.detail).to be(result1.detail) + end + end + + describe "fallthrough experiment/rollout behavior" do + it "evaluates rollout for fallthrough" do + flag = factory.flag({ + key: 'feature0', + on: true, + fallthrough: { rollout: { variations: [ { weight: 100000, variation: 1, untracked: false } ] } }, + offVariation: 1, + variations: ['a', 'b', 'c'], + version: 1 + }) + user = { key: 'x' } + detail = EvaluationDetail.new('b', 1, EvaluationReason::fallthrough) + result = basic_evaluator.evaluate(flag, user) + expect(result.detail).to eq(detail) + expect(result.prereq_evals).to eq(nil) + end + + if factory.with_preprocessing + it "reuses fallthrough rollout result detail instance" do + flag = factory.flag({ + key: 'feature0', + on: true, + fallthrough: { rollout: { variations: [ { weight: 100000, variation: 1, untracked: false } ] } }, + offVariation: 1, + variations: ['a', 'b', 'c'], + version: 1 + }) + user = { key: 'x' } + detail = EvaluationDetail.new('b', 1, EvaluationReason::fallthrough) + result1 = basic_evaluator.evaluate(flag, user) + result2 = basic_evaluator.evaluate(flag, user) + expect(result1.detail).to eq(detail) + expect(result2.detail).to be(result1.detail) + end + end + it "sets the in_experiment value if rollout kind is experiment and untracked false" do - flag = { + flag = factory.flag({ key: 'feature', on: true, fallthrough: { rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: false } ] } }, offVariation: 1, variations: ['a', 'b', 'c'] - } + }) user = { key: 'userkey' } result = basic_evaluator.evaluate(flag, user) expect(result.detail.reason.to_json).to include('"inExperiment":true') @@ -313,13 +428,13 @@ module Impl end it "does not set the in_experiment value if rollout kind is not experiment" do - flag = { + flag = factory.flag({ key: 'feature', on: true, fallthrough: { rollout: { kind: 'rollout', variations: [ { weight: 100000, variation: 1, untracked: false } ] } }, offVariation: 1, variations: ['a', 'b', 'c'] - } + }) user = { key: 'userkey' } result = basic_evaluator.evaluate(flag, user) expect(result.detail.reason.to_json).to_not include('"inExperiment":true') @@ -327,13 +442,13 @@ module Impl end it "does not set the in_experiment value if rollout kind is experiment and untracked is true" do - flag = { + flag = factory.flag({ key: 'feature', on: true, fallthrough: { rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: true } ] } }, offVariation: 1, variations: ['a', 'b', 'c'] - } + }) user = { key: 'userkey' } result = basic_evaluator.evaluate(flag, user) expect(result.detail.reason.to_json).to_not include('"inExperiment":true') diff --git a/spec/impl/evaluator_spec_base.rb b/spec/impl/evaluator_spec_base.rb index 6008c8b9..fc1f0414 100644 --- a/spec/impl/evaluator_spec_base.rb +++ b/spec/impl/evaluator_spec_base.rb @@ -1,7 +1,22 @@ require "ldclient-rb/impl/big_segments" +require "model_builders" require "spec_helper" +def evaluator_tests_with_and_without_preprocessing(desc_base) + # In the evaluator tests, we are really testing two sets of evaluation logic: one where preprocessed + # results are not available, and one where they are. In normal usage, flags always get preprocessed and + # we expect evaluations to almost always be able to reuse a preprocessed result-- but we still want to + # verify that the evaluator works even if preprocessing hasn't happened, since a flag is just a Hash and + # so we can't do any type-level enforcement to constrain its state. The DataItemFactory abstraction + # controls whether flags/segments created in these tests do or do not have preprocessing applied. + [true, false].each do |with_preprocessing| + pre_desc = with_preprocessing ? "with preprocessing" : "without preprocessing" + desc = "#{desc_base} - #{pre_desc}" + yield desc, DataItemFactory.new(with_preprocessing) + end +end + module LaunchDarkly module Impl class EvaluatorBuilder @@ -91,14 +106,6 @@ def basic_evaluator EvaluatorBuilder.new(logger).build end - def boolean_flag_with_rules(rules) - { key: 'feature', on: true, rules: rules, fallthrough: { variation: 0 }, variations: [ false, true ] } - end - - def boolean_flag_with_clauses(clauses) - boolean_flag_with_rules([{ id: 'ruleid', clauses: clauses, variation: 1 }]) - end - def make_user_matching_clause(user, attr = :key) { attribute: attr.to_s, diff --git a/spec/impl/model/preprocessed_data_spec.rb b/spec/impl/model/preprocessed_data_spec.rb new file mode 100644 index 00000000..c805a3d2 --- /dev/null +++ b/spec/impl/model/preprocessed_data_spec.rb @@ -0,0 +1,45 @@ +require "model_builders" +require "spec_helper" + +def strip_preprocessed_nulls(json) + # currently we can't avoid emitting these null properties - we just don't want to see anything other than null there + json.gsub('"_preprocessed":null,', '').gsub(',"_preprocessed":null', '') +end + +module LaunchDarkly + module Impl + module DataModelPreprocessing + describe "preprocessed data is not emitted in JSON" do + it "for flag" do + original_flag = { + key: 'flagkey', + version: 1, + on: true, + offVariation: 0, + variations: [true, false], + fallthroughVariation: 1, + prerequisites: [ + { key: 'a', variation: 0 } + ], + targets: [ + { variation: 0, values: ['a'] } + ], + rules: [ + { + variation: 0, + clauses: [ + { attribute: 'key', op: 'in', values: ['a'] } + ] + } + ] + } + flag = clone_json_object(original_flag) + Preprocessor.new().preprocess_flag!(flag) + json = Model.serialize(FEATURES, flag) + parsed = JSON.parse(strip_preprocessed_nulls(json), symbolize_names: true) + expect(parsed).to eq(original_flag) + end + end + end + end +end diff --git a/spec/impl/model/serialization_spec.rb b/spec/impl/model/serialization_spec.rb index 0a26bcd5..0d6fa4de 100644 --- a/spec/impl/model/serialization_spec.rb +++ b/spec/impl/model/serialization_spec.rb @@ -1,9 +1,12 @@ +require "model_builders" require "spec_helper" module LaunchDarkly module Impl module Model describe "model serialization" do + factory = DataItemFactory.new(true) # true = enable the usual preprocessing logic + it "serializes flag" do flag = { key: "flagkey", version: 1 } json = Model.serialize(FEATURES, flag) @@ -24,16 +27,18 @@ module Model it "deserializes flag with no rules or prerequisites" do flag_in = { key: "flagkey", version: 1 } - json = Model.serialize(FEATURES, flag_in) + flag_preprocessed = factory.flag(flag_in) + json = Model.serialize(FEATURES, flag_preprocessed) flag_out = Model.deserialize(FEATURES, json) - expect(flag_out).to eq flag_in + expect(flag_out).to eq flag_preprocessed end it "deserializes segment" do segment_in = { key: "segkey", version: 1 } - json = Model.serialize(SEGMENTS, segment_in) + segment_preprocessed = factory.segment(segment_in) + json = Model.serialize(SEGMENTS, segment_preprocessed) segment_out = Model.deserialize(SEGMENTS, json) - expect(segment_out).to eq segment_in + expect(segment_out).to eq factory.segment(segment_preprocessed) end end end diff --git a/spec/model_builders.rb b/spec/model_builders.rb index a7c0bd6e..366155da 100644 --- a/spec/model_builders.rb +++ b/spec/model_builders.rb @@ -1,3 +1,45 @@ +require "ldclient-rb/impl/model/preprocessed_data" +require "json" + +def clone_json_object(o) + JSON.parse(o.to_json, symbolize_names: true) +end + +class DataItemFactory + def initialize(with_preprocessing) + @with_preprocessing = with_preprocessing + end + + def flag(flag_data) + @with_preprocessing ? preprocessed_flag(flag_data) : flag_data + end + + def segment(segment_data) + @with_preprocessing ? preprocessed_segment(segment_data) : segment_data + end + + def boolean_flag_with_rules(rules) + flag({ key: 'feature', on: true, rules: rules, fallthrough: { variation: 0 }, variations: [ false, true ] }) + end + + def boolean_flag_with_clauses(clauses) + flag(boolean_flag_with_rules([{ id: 'ruleid', clauses: clauses, variation: 1 }])) + end + + attr_reader :with_preprocessing + + private def preprocessed_flag(o) + ret = clone_json_object(o) + LaunchDarkly::Impl::DataModelPreprocessing::Preprocessor.new().preprocess_flag!(ret) + ret + end + + private def preprocessed_segment(o) + ret = clone_json_object(o) + LaunchDarkly::Impl::DataModelPreprocessing::Preprocessor.new().preprocess_segment!(ret) + ret + end +end class FlagBuilder def initialize(key) @@ -10,7 +52,7 @@ def initialize(key) end def build - @flag.clone + DataItemFactory.new(true).flag(@flag) end def version(value) @@ -111,7 +153,7 @@ def initialize(key) end def build - @segment.clone + DataItemFactory.new(true).segment(@segment) end def included(*keys) diff --git a/spec/requestor_spec.rb b/spec/requestor_spec.rb index 65ec7ed3..f9f40fa0 100644 --- a/spec/requestor_spec.rb +++ b/spec/requestor_spec.rb @@ -1,11 +1,14 @@ require "http_util" +require "model_builders" require "spec_helper" $sdk_key = "secret" describe LaunchDarkly::Requestor do + factory = DataItemFactory.new(true) # true = enable the usual preprocessing logic + def with_requestor(base_uri, opts = {}) - r = LaunchDarkly::Requestor.new($sdk_key, LaunchDarkly::Config.new({ base_uri: base_uri }.merge(opts))) + r = LaunchDarkly::Requestor.new($sdk_key, LaunchDarkly::Config.new({ base_uri: base_uri, application: {id: "id", version: "version"} }.merge(opts))) begin yield r ensure @@ -23,14 +26,15 @@ def with_requestor(base_uri, opts = {}) expect(server.requests[0].unparsed_uri).to eq "/sdk/latest-all" expect(server.requests[0].header).to include({ "authorization" => [ $sdk_key ], - "user-agent" => [ "RubyClient/" + LaunchDarkly::VERSION ] + "user-agent" => [ "RubyClient/" + LaunchDarkly::VERSION ], + "x-launchdarkly-tags" => [ "application-id/id application-version/version" ], }) end end end it "parses response" do - expected_data = { flags: { x: { key: "x" } } } + expected_data = { flags: { x: factory.flag({ key: "x" }) } } with_server do |server| with_requestor(server.base_uri.to_s) do |requestor| server.setup_ok_response("/", expected_data.to_json) @@ -84,10 +88,10 @@ def with_requestor(base_uri, opts = {}) end end end - + it "can reuse cached data" do etag = "xyz" - expected_data = { flags: { x: { key: "x" } } } + expected_data = { flags: { x: factory.flag({ key: "x" }) } } with_server do |server| with_requestor(server.base_uri.to_s) do |requestor| server.setup_response("/") do |req, res| @@ -112,8 +116,8 @@ def with_requestor(base_uri, opts = {}) it "replaces cached data with new data" do etag1 = "abc" etag2 = "xyz" - expected_data1 = { flags: { x: { key: "x" } } } - expected_data2 = { flags: { y: { key: "y" } } } + expected_data1 = { flags: { x: factory.flag({ key: "x" }) } } + expected_data2 = { flags: { y: factory.flag({ key: "y" }) } } with_server do |server| with_requestor(server.base_uri.to_s) do |requestor| server.setup_response("/") do |req, res| @@ -196,7 +200,7 @@ def with_requestor(base_uri, opts = {}) # use a real proxy that really forwards requests to another test server, because # that test server would be at localhost, and proxy environment variables are # ignored if the target is localhost. - expected_data = { flags: { flagkey: { key: "flagkey" } } } + expected_data = { flags: { flagkey: factory.flag({ key: "flagkey" }) } } with_server do |proxy| proxy.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json", { "etag" => "x" }) begin diff --git a/spec/stream_spec.rb b/spec/stream_spec.rb index 39c678c4..4f2d7b85 100644 --- a/spec/stream_spec.rb +++ b/spec/stream_spec.rb @@ -1,7 +1,10 @@ require "ld-eventsource" +require "model_builders" require "spec_helper" describe LaunchDarkly::StreamProcessor do + factory = DataItemFactory.new(true) # true = enable the usual preprocessing logic + subject { LaunchDarkly::StreamProcessor } let(:config) { LaunchDarkly::Config.new } let(:processor) { subject.new("sdk_key", config) } @@ -15,16 +18,16 @@ it "will accept PUT methods" do processor.send(:process_message, put_message) - expect(config.feature_store.get(LaunchDarkly::FEATURES, "asdf")).to eq(key: "asdf") - expect(config.feature_store.get(LaunchDarkly::SEGMENTS, "segkey")).to eq(key: "segkey") + expect(config.feature_store.get(LaunchDarkly::FEATURES, "asdf")).to eq(factory.flag(key: "asdf")) + expect(config.feature_store.get(LaunchDarkly::SEGMENTS, "segkey")).to eq(factory.segment(key: "segkey")) end it "will accept PATCH methods for flags" do processor.send(:process_message, patch_flag_message) - expect(config.feature_store.get(LaunchDarkly::FEATURES, "asdf")).to eq(key: "asdf", version: 1) + expect(config.feature_store.get(LaunchDarkly::FEATURES, "asdf")).to eq(factory.flag(key: "asdf", version: 1)) end it "will accept PATCH methods for segments" do processor.send(:process_message, patch_seg_message) - expect(config.feature_store.get(LaunchDarkly::SEGMENTS, "asdf")).to eq(key: "asdf", version: 1) + expect(config.feature_store.get(LaunchDarkly::SEGMENTS, "asdf")).to eq(factory.segment(key: "asdf", version: 1)) end it "will accept DELETE methods for flags" do processor.send(:process_message, patch_flag_message)