diff --git a/.circleci/config.yml b/.circleci/config.yml index c6ff6938..6e7dd560 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,12 +4,10 @@ workflows: version: 2 test: jobs: - - test-misc-rubies - - test-2.2 - - test-2.3 - - test-2.4 - test-2.5 - test-2.6 + - test-2.7 + - test-3.0 - test-jruby-9.2 ruby-docker-template: &ruby-docker-template @@ -18,9 +16,10 @@ ruby-docker-template: &ruby-docker-template - run: | if [[ $CIRCLE_JOB == test-jruby* ]]; then gem install jruby-openssl; # required by bundler, no effect on Ruby MRI + sudo apt-get update -y && sudo apt-get install -y build-essential fi - run: ruby -v - - run: gem install bundler -v 1.17.3 + - run: gem install bundler - run: bundle install - run: mkdir ./rspec - run: bundle exec rspec --format progress --format RspecJunitFormatter -o ./rspec/rspec.xml spec @@ -30,105 +29,38 @@ ruby-docker-template: &ruby-docker-template path: ./rspec jobs: - test-2.2: - <<: *ruby-docker-template - docker: - - image: circleci/ruby:2.2.10-jessie - - image: consul - - image: redis - - image: amazon/dynamodb-local - test-2.3: + test-2.5: <<: *ruby-docker-template docker: - - image: circleci/ruby:2.3.7-jessie + - image: circleci/ruby:2.5 - image: consul - image: redis - image: amazon/dynamodb-local - test-2.4: + test-2.6: <<: *ruby-docker-template docker: - - image: circleci/ruby:2.4.5-stretch + - image: circleci/ruby:2.6 - image: consul - image: redis - image: amazon/dynamodb-local - test-2.5: + test-2.7: <<: *ruby-docker-template docker: - - image: circleci/ruby:2.5.3-stretch + - image: circleci/ruby:2.7 - image: consul - image: redis - image: amazon/dynamodb-local - test-2.6: + test-3.0: <<: *ruby-docker-template docker: - - image: circleci/ruby:2.6.2-stretch + - image: circleci/ruby:3.0 - image: consul - image: redis - image: amazon/dynamodb-local test-jruby-9.2: <<: *ruby-docker-template docker: - - image: circleci/jruby:9-jdk + - image: circleci/jruby:9.2-jdk - image: consul - image: redis - image: amazon/dynamodb-local - - # The following very slow job uses an Ubuntu container to run the Ruby versions that - # CircleCI doesn't provide Docker images for. - test-misc-rubies: - machine: - image: circleci/classic:latest - environment: - - RUBIES: "jruby-9.1.17.0" - steps: - - run: sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" - - run: sudo apt-get -q update - - run: sudo apt-get -qy install redis-server - - run: sudo apt-cache policy docker-ce - - run: sudo apt-get -qy install docker-ce - - checkout - - run: - name: install all Ruby versions - command: "parallel rvm install ::: $RUBIES" - - run: - name: bundle install for all versions - shell: /bin/bash -leo pipefail # need -l in order for "rvm use" to work - command: | - set -e; - for i in $RUBIES; - do - rvm use $i; - if [[ $i == jruby* ]]; then - gem install jruby-openssl; # required by bundler, no effect on Ruby MRI - fi - # bundler 2.0 may be preinstalled, we need to remove it if so - yes | gem uninstall bundler --version '>=2.0' || true; - gem install bundler -v 1.17.3; - bundle install; - mv Gemfile.lock "Gemfile.lock.$i" - done - - run: - name: start DynamoDB - command: docker run -p 8000:8000 amazon/dynamodb-local - background: true - - run: - name: download Consul - command: wget https://releases.hashicorp.com/consul/0.8.0/consul_0.8.0_linux_amd64.zip - - run: - name: extract Consul - command: unzip consul_0.8.0_linux_amd64.zip - - run: - name: start Consul - command: ./consul agent -dev - background: true - - run: - name: run tests for all versions - shell: /bin/bash -leo pipefail - command: | - set -e; - for i in $RUBIES; - do - rvm use $i; - cp "Gemfile.lock.$i" Gemfile.lock; - bundle exec rspec spec; - done diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 7ae02a5a..4f3d0b67 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -2,16 +2,25 @@ repo: public: ruby-server-sdk private: ruby-server-sdk-private +releasableBranches: + - name: master + - name: 5.x + publications: - url: https://rubygems.org/gems/launchdarkly-server-sdk description: RubyGems - url: https://www.rubydoc.info/gems/launchdarkly-server-sdk description: documentation -template: - name: ruby - env: - LD_SKIP_DATABASE_TESTS: 1 # Don't run Redis/Consul/DynamoDB tests in release; they are run in CI +circleci: + linux: + image: circleci/ruby:2.6.2-stretch + context: org-global + env: + LD_SKIP_DATABASE_TESTS: "1" # Don't run Redis/Consul/DynamoDB tests in release; they are run in CI + +documentation: + githubPages: true circleci: linux: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac126eec..fb244f5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ Build instructions ### Prerequisites -This SDK is built with [Bundler](https://bundler.io/). To install Bundler, run `gem install bundler -v 1.17.3`. You might need `sudo` to execute the command successfully. As of this writing, the SDK does not support being built with Bundler 2.0. +This SDK is built with [Bundler](https://bundler.io/). To install Bundler, run `gem install bundler`. You might need `sudo` to execute the command successfully. To install the runtime dependencies: diff --git a/Gemfile.lock b/Gemfile.lock index 81bd5ac1..632f9dcf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,88 +2,115 @@ PATH remote: . specs: launchdarkly-server-sdk (5.8.2) - concurrent-ruby (~> 1.0) + concurrent-ruby (~> 1.1) + http (~> 4.4.1) json (~> 2.3.1) - ld-eventsource (= 1.0.3) + ld-eventsource (~> 2.0) semantic (~> 1.6) GEM remote: https://rubygems.org/ specs: + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) + ansi (1.5.0) + ast (2.4.2) aws-eventstream (1.1.0) - aws-partitions (1.388.0) - aws-sdk-core (3.109.1) + aws-partitions (1.418.0) + aws-sdk-core (3.111.2) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-dynamodb (1.55.0) + aws-sdk-dynamodb (1.58.0) aws-sdk-core (~> 3, >= 3.109.0) aws-sigv4 (~> 1.1) aws-sigv4 (1.2.2) aws-eventstream (~> 1, >= 1.0.2) - concurrent-ruby (1.1.7) + concurrent-ruby (1.1.8) connection_pool (2.2.3) deep_merge (1.2.1) diff-lcs (1.4.4) diplomat (2.4.2) deep_merge (~> 1.0, >= 1.0.1) faraday (>= 0.9, < 1.1.0) - faraday (0.17.3) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + faraday (1.0.1) multipart-post (>= 1.2, < 3) - ffi (1.12.0) - hitimes (1.3.1) - http_tools (0.4.5) + ffi (1.14.2) + ffi-compiler (1.0.1) + ffi (>= 1.0.0) + rake + http (4.4.1) + addressable (~> 2.3) + http-cookie (~> 1.0) + http-form_data (~> 2.2) + http-parser (~> 1.2.0) + http-cookie (1.0.3) + domain_name (~> 0.5) + http-form_data (2.3.0) + http-parser (1.2.3) + ffi-compiler (>= 1.0, < 2.0) jmespath (1.4.0) json (2.3.1) - ld-eventsource (1.0.3) + ld-eventsource (2.0.0) concurrent-ruby (~> 1.0) - http_tools (~> 0.4.5) - socketry (~> 0.5.1) - listen (3.2.1) + http (~> 4.4.1) + listen (3.4.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) multipart-post (2.1.1) + oga (2.15) + ast + ruby-ll (~> 2.1) + public_suffix (4.0.6) + rake (13.0.3) rb-fsevent (0.10.4) rb-inotify (0.10.1) ffi (~> 1.0) - redis (3.3.5) - rspec (3.9.0) - rspec-core (~> 3.9.0) - rspec-expectations (~> 3.9.0) - rspec-mocks (~> 3.9.0) - rspec-core (3.9.3) - rspec-support (~> 3.9.3) - rspec-expectations (3.9.3) + redis (4.2.5) + rspec (3.10.0) + rspec-core (~> 3.10.0) + rspec-expectations (~> 3.10.0) + rspec-mocks (~> 3.10.0) + rspec-core (3.10.1) + rspec-support (~> 3.10.0) + rspec-expectations (3.10.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-mocks (3.9.1) + rspec-support (~> 3.10.0) + rspec-mocks (3.10.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-support (3.9.4) - rspec_junit_formatter (0.3.0) + rspec-support (~> 3.10.0) + rspec-support (3.10.1) + rspec_junit_formatter (0.4.1) rspec-core (>= 2, < 4, != 2.12.0) + ruby-ll (2.1.2) + ansi + ast semantic (1.6.1) - socketry (0.5.1) - hitimes (~> 1.2) timecop (0.9.2) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.7) + webrick (1.7.0) PLATFORMS ruby DEPENDENCIES - aws-sdk-dynamodb (~> 1.18) - bundler (~> 1.17) - connection_pool (>= 2.1.2) - diplomat (>= 2.0.2) - faraday (~> 0.17) - ffi (<= 1.12) + aws-sdk-dynamodb (~> 1.57) + bundler (~> 2.1) + connection_pool (~> 2.2.3) + diplomat (~> 2.4.2) launchdarkly-server-sdk! - listen (~> 3.0) - redis (~> 3.3.5) - rspec (~> 3.2) - rspec_junit_formatter (~> 0.3.0) - timecop (~> 0.9.1) + listen (~> 3.3) + oga (~> 2.2) + redis (~> 4.2) + rspec (~> 3.10) + rspec_junit_formatter (~> 0.4) + timecop (~> 0.9) + webrick (~> 1.7) BUNDLED WITH - 1.17.3 + 2.2.3 diff --git a/README.md b/README.md index bc6cf21d..ef8c0e33 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ LaunchDarkly overview Supported Ruby versions ----------------------- -This version of the LaunchDarkly SDK has a minimum Ruby version of 2.2.6, or 9.1.6 for JRuby. +This version of the LaunchDarkly SDK has a minimum Ruby version of 2.5.0, or 9.2.0 for JRuby. Getting started ----------- @@ -55,4 +55,4 @@ About LaunchDarkly * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates - * [Feature Flagging Guide](https://github.com/launchdarkly/featureflags/ "Feature Flagging Guide") for best practices and strategies \ No newline at end of file + * [Feature Flagging Guide](https://github.com/launchdarkly/featureflags/ "Feature Flagging Guide") for best practices and strategies diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3d3fd98a..88296f02 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -45,7 +45,7 @@ jobs: workingDirectory: $(System.DefaultWorkingDirectory) script: | ruby -v - gem install bundler -v 1.17.3 + gem install bundler bundle install mkdir rspec bundle exec rspec --format progress --format RspecJunitFormatter -o ./rspec/rspec.xml spec diff --git a/launchdarkly-server-sdk.gemspec b/launchdarkly-server-sdk.gemspec index b8493985..dcf281fe 100644 --- a/launchdarkly-server-sdk.gemspec +++ b/launchdarkly-server-sdk.gemspec @@ -19,27 +19,27 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ["lib"] + spec.required_ruby_version = ">= 2.5.0" - spec.add_development_dependency "aws-sdk-dynamodb", "~> 1.18" - spec.add_development_dependency "bundler", "~> 1.17" - spec.add_development_dependency "rspec", "~> 3.2" - spec.add_development_dependency "diplomat", ">= 2.0.2" - spec.add_development_dependency "redis", "~> 3.3.5" - spec.add_development_dependency "connection_pool", ">= 2.1.2" - spec.add_development_dependency "rspec_junit_formatter", "~> 0.3.0" - spec.add_development_dependency "timecop", "~> 0.9.1" - spec.add_development_dependency "listen", "~> 3.0" # see file_data_source.rb - # these are transitive dependencies of listen and consul respectively - # we constrain them here to make sure the ruby 2.2, 2.3, and 2.4 CI - # cases all pass - spec.add_development_dependency "ffi", "<= 1.12" # >1.12 doesnt support ruby 2.2 - spec.add_development_dependency "faraday", "~> 0.17" # >=0.18 doesnt support ruby 2.2 + spec.add_development_dependency "aws-sdk-dynamodb", "~> 1.57" + spec.add_development_dependency "bundler", "~> 2.1" + spec.add_development_dependency "rspec", "~> 3.10" + spec.add_development_dependency "diplomat", "~> 2.4.2" + spec.add_development_dependency "redis", "~> 4.2" + spec.add_development_dependency "connection_pool", "~> 2.2.3" + spec.add_development_dependency "rspec_junit_formatter", "~> 0.4" + spec.add_development_dependency "timecop", "~> 0.9" + spec.add_development_dependency "listen", "~> 3.3" # see file_data_source.rb + spec.add_development_dependency "webrick", "~> 1.7" + # required by dynamodb + spec.add_development_dependency "oga", "~> 2.2" spec.add_runtime_dependency "semantic", "~> 1.6" - spec.add_runtime_dependency "concurrent-ruby", "~> 1.0" - spec.add_runtime_dependency "ld-eventsource", "1.0.3" + spec.add_runtime_dependency "concurrent-ruby", "~> 1.1" + spec.add_runtime_dependency "ld-eventsource", "~> 2.0" # lock json to 2.3.x as ruby libraries often remove # support for older ruby versions in minor releases spec.add_runtime_dependency "json", "~> 2.3.1" + spec.add_runtime_dependency "http", "~> 4.4.1" end diff --git a/lib/ldclient-rb.rb b/lib/ldclient-rb.rb index e5477ecb..9a215686 100644 --- a/lib/ldclient-rb.rb +++ b/lib/ldclient-rb.rb @@ -8,7 +8,6 @@ module LaunchDarkly require "ldclient-rb/version" require "ldclient-rb/interfaces" require "ldclient-rb/util" -require "ldclient-rb/evaluation" require "ldclient-rb/flags_state" require "ldclient-rb/ldclient" require "ldclient-rb/cache_store" diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index f3612756..edb21924 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -15,7 +15,7 @@ class Config # # @param opts [Hash] the configuration options # @option opts [Logger] :logger See {#logger}. - # @option opts [String] :base_uri ("https://app.launchdarkly.com") See {#base_uri}. + # @option opts [String] :base_uri ("https://sdk.launchdarkly.com") See {#base_uri}. # @option opts [String] :stream_uri ("https://stream.launchdarkly.com") See {#stream_uri}. # @option opts [String] :events_uri ("https://events.launchdarkly.com") See {#events_uri}. # @option opts [Integer] :capacity (10000) See {#capacity}. @@ -41,6 +41,7 @@ class Config # @option opts [Float] :diagnostic_recording_interval (900) See {#diagnostic_recording_interval}. # @option opts [String] :wrapper_name See {#wrapper_name}. # @option opts [String] :wrapper_version See {#wrapper_version}. + # @option opts [#open] :socket_factory See {#socket_factory}. # def initialize(opts = {}) @base_uri = (opts[:base_uri] || Config.default_base_uri).chomp("/") @@ -71,6 +72,7 @@ def initialize(opts = {}) opts[:diagnostic_recording_interval] : Config.default_diagnostic_recording_interval @wrapper_name = opts[:wrapper_name] @wrapper_version = opts[:wrapper_version] + @socket_factory = opts[:socket_factory] end # @@ -305,6 +307,16 @@ def diagnostic_opt_out? # attr_reader :wrapper_version + # + # The factory used to construct sockets for HTTP operations. The factory must + # provide the method `open(uri, timeout)`. The `open` method must return a + # connected stream that implements the `IO` class, such as a `TCPSocket`. + # + # Defaults to nil. + # @return [#open] + # + attr_reader :socket_factory + # # The default LaunchDarkly client configuration. This configuration sets # reasonable defaults for most users. @@ -324,10 +336,10 @@ def self.default_capacity # # The default value for {#base_uri}. - # @return [String] "https://app.launchdarkly.com" + # @return [String] "https://sdk.launchdarkly.com" # def self.default_base_uri - "https://app.launchdarkly.com" + "https://sdk.launchdarkly.com" end # diff --git a/lib/ldclient-rb/evaluation.rb b/lib/ldclient-rb/evaluation.rb deleted file mode 100644 index 3c18e7ff..00000000 --- a/lib/ldclient-rb/evaluation.rb +++ /dev/null @@ -1,462 +0,0 @@ -require "date" -require "semantic" - -module LaunchDarkly - # An object returned by {LDClient#variation_detail}, combining the result of a flag evaluation with - # an explanation of how it was calculated. - class EvaluationDetail - def initialize(value, variation_index, reason) - @value = value - @variation_index = variation_index - @reason = reason - end - - # - # The result of the flag evaluation. This will be either one of the flag's variations, or the - # default value that was passed to {LDClient#variation_detail}. It is the same as the return - # value of {LDClient#variation}. - # - # @return [Object] - # - attr_reader :value - - # - # The index of the returned value within the flag's list of variations. The first variation is - # 0, the second is 1, etc. This is `nil` if the default value was returned. - # - # @return [int|nil] - # - attr_reader :variation_index - - # - # An object describing the main factor that influenced the flag evaluation value. - # - # This object is currently represented as a Hash, which may have the following keys: - # - # `:kind`: The general category of reason. Possible values: - # - # * `'OFF'`: the flag was off and therefore returned its configured off value - # * `'FALLTHROUGH'`: the flag was on but the user did not match any targets or rules - # * `'TARGET_MATCH'`: the user key was specifically targeted for this flag - # * `'RULE_MATCH'`: the user matched one of the flag's rules - # * `'PREREQUISITE_FAILED`': the flag was considered off because it had at least one - # prerequisite flag that either was off or did not return the desired variation - # * `'ERROR'`: the flag could not be evaluated, so the default value was returned - # - # `:ruleIndex`: If the kind was `RULE_MATCH`, this is the positional index of the - # matched rule (0 for the first rule). - # - # `:ruleId`: If the kind was `RULE_MATCH`, this is the rule's unique identifier. - # - # `:prerequisiteKey`: If the kind was `PREREQUISITE_FAILED`, this is the flag key of - # the prerequisite flag that failed. - # - # `:errorKind`: If the kind was `ERROR`, this indicates the type of error: - # - # * `'CLIENT_NOT_READY'`: the caller tried to evaluate a flag before the client had - # successfully initialized - # * `'FLAG_NOT_FOUND'`: the caller provided a flag key that did not match any known flag - # * `'MALFORMED_FLAG'`: there was an internal inconsistency in the flag data, e.g. a - # rule specified a nonexistent variation - # * `'USER_NOT_SPECIFIED'`: the user object or user key was not provied - # * `'EXCEPTION'`: an unexpected exception stopped flag evaluation - # - # @return [Hash] - # - attr_reader :reason - - # - # Tests whether the flag evaluation returned a default value. This is the same as checking - # whether {#variation_index} is nil. - # - # @return [Boolean] - # - def default_value? - variation_index.nil? - end - - def ==(other) - @value == other.value && @variation_index == other.variation_index && @reason == other.reason - end - end - - # @private - module Evaluation - BUILTINS = [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous] - - NUMERIC_VERSION_COMPONENTS_REGEX = Regexp.new("^[0-9.]*") - - DATE_OPERAND = lambda do |v| - if v.is_a? String - begin - DateTime.rfc3339(v).strftime("%Q").to_i - rescue => e - nil - end - elsif v.is_a? Numeric - v - else - nil - end - end - - SEMVER_OPERAND = lambda do |v| - semver = nil - if v.is_a? String - for _ in 0..2 do - begin - semver = Semantic::Version.new(v) - break # Some versions of jruby cannot properly handle a return here and return from the method that calls this lambda - rescue ArgumentError - v = addZeroVersionComponent(v) - end - end - end - semver - end - - def self.addZeroVersionComponent(v) - NUMERIC_VERSION_COMPONENTS_REGEX.match(v) { |m| - m[0] + ".0" + v[m[0].length..-1] - } - end - - def self.comparator(converter) - lambda do |a, b| - av = converter.call(a) - bv = converter.call(b) - if !av.nil? && !bv.nil? - yield av <=> bv - else - return false - end - end - end - - OPERATORS = { - in: - lambda do |a, b| - a == b - end, - endsWith: - lambda do |a, b| - (a.is_a? String) && (b.is_a? String) && (a.end_with? b) - end, - startsWith: - lambda do |a, b| - (a.is_a? String) && (b.is_a? String) && (a.start_with? b) - end, - matches: - lambda do |a, b| - if (b.is_a? String) && (b.is_a? String) - begin - re = Regexp.new b - !re.match(a).nil? - rescue - false - end - else - false - end - end, - contains: - lambda do |a, b| - (a.is_a? String) && (b.is_a? String) && (a.include? b) - end, - lessThan: - lambda do |a, b| - (a.is_a? Numeric) && (b.is_a? Numeric) && (a < b) - end, - lessThanOrEqual: - lambda do |a, b| - (a.is_a? Numeric) && (b.is_a? Numeric) && (a <= b) - end, - greaterThan: - lambda do |a, b| - (a.is_a? Numeric) && (b.is_a? Numeric) && (a > b) - end, - greaterThanOrEqual: - lambda do |a, b| - (a.is_a? Numeric) && (b.is_a? Numeric) && (a >= b) - end, - before: - comparator(DATE_OPERAND) { |n| n < 0 }, - after: - comparator(DATE_OPERAND) { |n| n > 0 }, - semVerEqual: - comparator(SEMVER_OPERAND) { |n| n == 0 }, - semVerLessThan: - comparator(SEMVER_OPERAND) { |n| n < 0 }, - semVerGreaterThan: - comparator(SEMVER_OPERAND) { |n| n > 0 }, - segmentMatch: - lambda do |a, b| - false # we should never reach this - instead we special-case this operator in clause_match_user - end - } - - # Used internally to hold an evaluation result and the events that were generated from prerequisites. - EvalResult = Struct.new(:detail, :events) - - USER_ATTRS_TO_STRINGIFY_FOR_EVALUATION = [ :key, :secondary ] - # Currently we are not stringifying the rest of the built-in attributes prior to evaluation, only for events. - # This is because it could affect evaluation results for existing users (ch35206). - - def error_result(errorKind, value = nil) - EvaluationDetail.new(value, nil, { kind: 'ERROR', errorKind: errorKind }) - end - - # Evaluates a feature flag and returns an EvalResult. The result.value will be nil if the flag returns - # the default value. Error conditions produce a result with an error reason, not an exception. - def evaluate(flag, user, store, logger, event_factory) - if user.nil? || user[:key].nil? - return EvalResult.new(error_result('USER_NOT_SPECIFIED'), []) - end - - sanitized_user = Util.stringify_attrs(user, USER_ATTRS_TO_STRINGIFY_FOR_EVALUATION) - - events = [] - detail = eval_internal(flag, sanitized_user, store, events, logger, event_factory) - return EvalResult.new(detail, events) - end - - def eval_internal(flag, user, store, events, logger, event_factory) - if !flag[:on] - return get_off_value(flag, { kind: 'OFF' }, logger) - end - - prereq_failure_reason = check_prerequisites(flag, user, store, events, logger, event_factory) - if !prereq_failure_reason.nil? - return get_off_value(flag, prereq_failure_reason, logger) - end - - # Check user target matches - (flag[:targets] || []).each do |target| - (target[:values] || []).each do |value| - if value == user[:key] - return get_variation(flag, target[:variation], { kind: 'TARGET_MATCH' }, logger) - end - end - end - - # Check custom rules - rules = flag[:rules] || [] - rules.each_index do |i| - rule = rules[i] - if rule_match_user(rule, user, store) - return get_value_for_variation_or_rollout(flag, rule, user, - { kind: 'RULE_MATCH', ruleIndex: i, ruleId: rule[:id] }, logger) - end - end - - # Check the fallthrough rule - if !flag[:fallthrough].nil? - return get_value_for_variation_or_rollout(flag, flag[:fallthrough], user, - { kind: 'FALLTHROUGH' }, logger) - end - - return EvaluationDetail.new(nil, nil, { kind: 'FALLTHROUGH' }) - end - - def check_prerequisites(flag, user, store, events, logger, event_factory) - (flag[:prerequisites] || []).each do |prerequisite| - prereq_ok = true - prereq_key = prerequisite[:key] - prereq_flag = store.get(FEATURES, prereq_key) - - if prereq_flag.nil? - logger.error { "[LDClient] Could not retrieve prerequisite flag \"#{prereq_key}\" when evaluating \"#{flag[:key]}\"" } - prereq_ok = false - else - begin - prereq_res = eval_internal(prereq_flag, user, store, events, logger, event_factory) - # Note that if the prerequisite flag is off, we don't consider it a match no matter what its - # off variation was. But we still need to evaluate it in order to generate an event. - if !prereq_flag[:on] || prereq_res.variation_index != prerequisite[:variation] - prereq_ok = false - end - event = event_factory.new_eval_event(prereq_flag, user, prereq_res, nil, flag) - events.push(event) - rescue => exn - Util.log_exception(logger, "Error evaluating prerequisite flag \"#{prereq_key}\" for flag \"#{flag[:key]}\"", exn) - prereq_ok = false - end - end - if !prereq_ok - return { kind: 'PREREQUISITE_FAILED', prerequisiteKey: prereq_key } - end - end - nil - end - - def rule_match_user(rule, user, store) - return false if !rule[:clauses] - - (rule[:clauses] || []).each do |clause| - return false if !clause_match_user(clause, user, store) - end - - return true - end - - def clause_match_user(clause, user, store) - # In the case of a segment match operator, we check if the user is in any of the segments, - # and possibly negate - if clause[:op].to_sym == :segmentMatch - (clause[:values] || []).each do |v| - segment = store.get(SEGMENTS, v) - return maybe_negate(clause, true) if !segment.nil? && segment_match_user(segment, user) - end - return maybe_negate(clause, false) - end - clause_match_user_no_segments(clause, user) - end - - def clause_match_user_no_segments(clause, user) - val = user_value(user, clause[:attribute]) - return false if val.nil? - - op = OPERATORS[clause[:op].to_sym] - if op.nil? - return false - end - - if val.is_a? Enumerable - val.each do |v| - return maybe_negate(clause, true) if match_any(op, v, clause[:values]) - end - return maybe_negate(clause, false) - end - - maybe_negate(clause, match_any(op, val, clause[:values])) - end - - def variation_index_for_user(flag, rule, user) - variation = rule[:variation] - return variation if !variation.nil? # fixed variation - rollout = rule[:rollout] - return nil if rollout.nil? - variations = rollout[:variations] - if !variations.nil? && variations.length > 0 # percentage rollout - rollout = rule[:rollout] - bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy] - bucket = bucket_user(user, flag[:key], bucket_by, flag[:salt]) - sum = 0; - variations.each do |variate| - sum += variate[:weight].to_f / 100000.0 - if bucket < sum - return variate[:variation] - end - end - # The user's bucket value was greater than or equal to the end of the last bucket. This could happen due - # to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag - # data could contain buckets that don't actually add up to 100000. Rather than returning an error in - # this case (or changing the scaling, which would potentially change the results for *all* users), we - # will simply put the user in the last bucket. - variations[-1][:variation] - else # the rule isn't well-formed - nil - end - end - - def segment_match_user(segment, user) - return false unless user[:key] - - return true if segment[:included].include?(user[:key]) - return false if segment[:excluded].include?(user[:key]) - - (segment[:rules] || []).each do |r| - return true if segment_rule_match_user(r, user, segment[:key], segment[:salt]) - end - - return false - end - - def segment_rule_match_user(rule, user, segment_key, salt) - (rule[:clauses] || []).each do |c| - return false unless clause_match_user_no_segments(c, user) - end - - # If the weight is absent, this rule matches - return true if !rule[:weight] - - # All of the clauses are met. See if the user buckets in - bucket = bucket_user(user, segment_key, rule[:bucketBy].nil? ? "key" : rule[:bucketBy], salt) - weight = rule[:weight].to_f / 100000.0 - return bucket < weight - end - - def bucket_user(user, key, bucket_by, salt) - return nil unless user[:key] - - id_hash = bucketable_string_value(user_value(user, bucket_by)) - if id_hash.nil? - return 0.0 - end - - if user[:secondary] - id_hash += "." + user[:secondary] - end - - hash_key = "%s.%s.%s" % [key, salt, id_hash] - - hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14] - hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF) - end - - def bucketable_string_value(value) - return value if value.is_a? String - return value.to_s if value.is_a? Integer - nil - end - - def user_value(user, attribute) - attribute = attribute.to_sym - - if BUILTINS.include? attribute - user[attribute] - elsif !user[:custom].nil? - user[:custom][attribute] - else - nil - end - end - - def maybe_negate(clause, b) - clause[:negate] ? !b : b - end - - def match_any(op, value, values) - values.each do |v| - return true if op.call(value, v) - end - return false - end - - private - - def get_variation(flag, index, reason, logger) - if index < 0 || index >= flag[:variations].length - logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": invalid variation index") - return error_result('MALFORMED_FLAG') - end - EvaluationDetail.new(flag[:variations][index], index, reason) - end - - def get_off_value(flag, reason, logger) - if flag[:offVariation].nil? # off variation unspecified - return default value - return EvaluationDetail.new(nil, nil, reason) - end - get_variation(flag, flag[:offVariation], reason, logger) - end - - def get_value_for_variation_or_rollout(flag, vr, user, reason, logger) - index = variation_index_for_user(flag, vr, user) - if index.nil? - logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": variation/rollout object with no variation or rollout") - return error_result('MALFORMED_FLAG') - end - return get_variation(flag, index, reason, logger) - end - end -end diff --git a/lib/ldclient-rb/evaluation_detail.rb b/lib/ldclient-rb/evaluation_detail.rb new file mode 100644 index 00000000..bccaf133 --- /dev/null +++ b/lib/ldclient-rb/evaluation_detail.rb @@ -0,0 +1,293 @@ + +module LaunchDarkly +# An object returned by {LDClient#variation_detail}, combining the result of a flag evaluation with + # an explanation of how it was calculated. + class EvaluationDetail + # Creates a new instance. + # + # @param value the result value of the flag evaluation; may be of any type + # @param variation_index [int|nil] the index of the value within the flag's list of variations, or + # `nil` if the application default value was returned + # @param reason [EvaluationReason] an object describing the main factor that influenced the result + # @raise [ArgumentError] if `variation_index` or `reason` is not of the correct type + def initialize(value, variation_index, reason) + raise ArgumentError.new("variation_index must be a number") if !variation_index.nil? && !(variation_index.is_a? Numeric) + raise ArgumentError.new("reason must be an EvaluationReason") if !(reason.is_a? EvaluationReason) + @value = value + @variation_index = variation_index + @reason = reason + end + + # + # The result of the flag evaluation. This will be either one of the flag's variations, or the + # default value that was passed to {LDClient#variation_detail}. It is the same as the return + # value of {LDClient#variation}. + # + # @return [Object] + # + attr_reader :value + + # + # The index of the returned value within the flag's list of variations. The first variation is + # 0, the second is 1, etc. This is `nil` if the default value was returned. + # + # @return [int|nil] + # + attr_reader :variation_index + + # + # An object describing the main factor that influenced the flag evaluation value. + # + # @return [EvaluationReason] + # + attr_reader :reason + + # + # Tests whether the flag evaluation returned a default value. This is the same as checking + # whether {#variation_index} is nil. + # + # @return [Boolean] + # + def default_value? + variation_index.nil? + end + + def ==(other) + @value == other.value && @variation_index == other.variation_index && @reason == other.reason + end + end + + # Describes the reason that a flag evaluation produced a particular value. This is returned by + # methods such as {LDClient#variation_detail} as the `reason` property of an {EvaluationDetail}. + # + # The `kind` property is always defined, but other properties will have non-nil values only for + # certain values of `kind`. All properties are immutable. + # + # There is a standard JSON representation of evaluation reasons when they appear in analytics events. + # Use `as_json` or `to_json` to convert to this representation. + # + # Use factory methods such as {EvaluationReason#off} to obtain instances of this class. + class EvaluationReason + # Value for {#kind} indicating that the flag was off and therefore returned its configured off value. + OFF = :OFF + + # Value for {#kind} indicating that the flag was on but the user did not match any targets or rules. + FALLTHROUGH = :FALLTHROUGH + + # Value for {#kind} indicating that the user key was specifically targeted for this flag. + TARGET_MATCH = :TARGET_MATCH + + # Value for {#kind} indicating that the user matched one of the flag's rules. + RULE_MATCH = :RULE_MATCH + + # Value for {#kind} indicating that the flag was considered off because it had at least one + # prerequisite flag that either was off or did not return the desired variation. + PREREQUISITE_FAILED = :PREREQUISITE_FAILED + + # Value for {#kind} indicating that the flag could not be evaluated, e.g. because it does not exist + # or due to an unexpected error. In this case the result value will be the application default value + # that the caller passed to the client. Check {#error_kind} for more details on the problem. + ERROR = :ERROR + + # Value for {#error_kind} indicating that the caller tried to evaluate a flag before the client had + # successfully initialized. + ERROR_CLIENT_NOT_READY = :CLIENT_NOT_READY + + # Value for {#error_kind} indicating that the caller provided a flag key that did not match any known flag. + ERROR_FLAG_NOT_FOUND = :FLAG_NOT_FOUND + + # Value for {#error_kind} indicating that there was an internal inconsistency in the flag data, e.g. + # a rule specified a nonexistent variation. An error message will always be logged in this case. + ERROR_MALFORMED_FLAG = :MALFORMED_FLAG + + # Value for {#error_kind} indicating that the caller passed `nil` for the user parameter, or the + # user lacked a key. + ERROR_USER_NOT_SPECIFIED = :USER_NOT_SPECIFIED + + # Value for {#error_kind} indicating that an unexpected exception stopped flag evaluation. An error + # message will always be logged in this case. + ERROR_EXCEPTION = :EXCEPTION + + # Indicates the general category of the reason. Will always be one of the class constants such + # as {#OFF}. + attr_reader :kind + + # The index of the rule that was matched (0 for the first rule in the feature flag). If + # {#kind} is not {#RULE_MATCH}, this will be `nil`. + attr_reader :rule_index + + # A unique string identifier for the matched rule, which will not change if other rules are added + # or deleted. If {#kind} is not {#RULE_MATCH}, this will be `nil`. + attr_reader :rule_id + + # The key of the prerequisite flag that did not return the desired variation. If {#kind} is not + # {#PREREQUISITE_FAILED}, this will be `nil`. + attr_reader :prerequisite_key + + # A value indicating the general category of error. This should be one of the class constants such + # as {#ERROR_FLAG_NOT_FOUND}. If {#kind} is not {#ERROR}, it will be `nil`. + attr_reader :error_kind + + # Returns an instance whose {#kind} is {#OFF}. + # @return [EvaluationReason] + def self.off + @@off + end + + # Returns an instance whose {#kind} is {#FALLTHROUGH}. + # @return [EvaluationReason] + def self.fallthrough + @@fallthrough + end + + # Returns an instance whose {#kind} is {#TARGET_MATCH}. + # @return [EvaluationReason] + def self.target_match + @@target_match + end + + # Returns an instance whose {#kind} is {#RULE_MATCH}. + # + # @param rule_index [Number] the index of the rule that was matched (0 for the first rule in + # the feature flag) + # @param rule_id [String] unique string identifier for the matched rule + # @return [EvaluationReason] + # @raise [ArgumentError] if `rule_index` is not a number or `rule_id` is not a string + def self.rule_match(rule_index, rule_id) + raise ArgumentError.new("rule_index must be a number") if !(rule_index.is_a? Numeric) + raise ArgumentError.new("rule_id must be a string") if !rule_id.nil? && !(rule_id.is_a? String) # in test data, ID could be nil + new(:RULE_MATCH, rule_index, rule_id, nil, nil) + end + + # Returns an instance whose {#kind} is {#PREREQUISITE_FAILED}. + # + # @param prerequisite_key [String] key of the prerequisite flag that did not return the desired variation + # @return [EvaluationReason] + # @raise [ArgumentError] if `prerequisite_key` is nil or not a string + def self.prerequisite_failed(prerequisite_key) + raise ArgumentError.new("prerequisite_key must be a string") if !(prerequisite_key.is_a? String) + new(:PREREQUISITE_FAILED, nil, nil, prerequisite_key, nil) + end + + # Returns an instance whose {#kind} is {#ERROR}. + # + # @param error_kind [Symbol] value indicating the general category of error + # @return [EvaluationReason] + # @raise [ArgumentError] if `error_kind` is not a symbol + def self.error(error_kind) + raise ArgumentError.new("error_kind must be a symbol") if !(error_kind.is_a? Symbol) + e = @@error_instances[error_kind] + e.nil? ? make_error(error_kind) : e + end + + def ==(other) + if other.is_a? EvaluationReason + @kind == other.kind && @rule_index == other.rule_index && @rule_id == other.rule_id && + @prerequisite_key == other.prerequisite_key && @error_kind == other.error_kind + elsif other.is_a? Hash + @kind.to_s == other[:kind] && @rule_index == other[:ruleIndex] && @rule_id == other[:ruleId] && + @prerequisite_key == other[:prerequisiteKey] && + (other[:errorKind] == @error_kind.nil? ? nil : @error_kind.to_s) + end + end + + # Equivalent to {#inspect}. + # @return [String] + def to_s + inspect + end + + # Returns a concise string representation of the reason. Examples: `"FALLTHROUGH"`, + # `"ERROR(FLAG_NOT_FOUND)"`. The exact syntax is not guaranteed to remain the same; this is meant + # for debugging. + # @return [String] + def inspect + case @kind + when :RULE_MATCH + "RULE_MATCH(#{@rule_index},#{@rule_id})" + when :PREREQUISITE_FAILED + "PREREQUISITE_FAILED(#{@prerequisite_key})" + when :ERROR + "ERROR(#{@error_kind})" + else + @kind.to_s + end + end + + # Returns a hash that can be used as a JSON representation of the reason, in the format used + # in LaunchDarkly analytics events. + # @return [Hash] + def as_json(*) # parameter is unused, but may be passed if we're using the json gem + # Note that this implementation is somewhat inefficient; it allocates a new hash every time. + # However, in normal usage the SDK only serializes reasons if 1. full event tracking is + # enabled for a flag and the application called variation_detail, or 2. experimentation is + # enabled for an evaluation. We can't reuse these hashes because an application could call + # as_json and then modify the result. + case @kind + when :RULE_MATCH + { kind: @kind, ruleIndex: @rule_index, ruleId: @rule_id } + when :PREREQUISITE_FAILED + { kind: @kind, prerequisiteKey: @prerequisite_key } + when :ERROR + { kind: @kind, errorKind: @error_kind } + else + { kind: @kind } + end + end + + # Same as {#as_json}, but converts the JSON structure into a string. + # @return [String] + def to_json(*a) + as_json.to_json(a) + end + + # Allows this object to be treated as a hash corresponding to its JSON representation. For + # instance, if `reason.kind` is {#RULE_MATCH}, then `reason[:kind]` will be `"RULE_MATCH"` and + # `reason[:ruleIndex]` will be equal to `reason.rule_index`. + def [](key) + case key + when :kind + @kind.to_s + when :ruleIndex + @rule_index + when :ruleId + @rule_id + when :prerequisiteKey + @prerequisite_key + when :errorKind + @error_kind.nil? ? nil : @error_kind.to_s + else + nil + end + end + + private + + def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind) + @kind = kind.to_sym + @rule_index = rule_index + @rule_id = rule_id + @rule_id.freeze if !rule_id.nil? + @prerequisite_key = prerequisite_key + @prerequisite_key.freeze if !prerequisite_key.nil? + @error_kind = error_kind + end + + private_class_method :new + + def self.make_error(error_kind) + new(:ERROR, nil, nil, nil, error_kind) + end + + @@fallthrough = new(:FALLTHROUGH, nil, nil, nil, nil) + @@off = new(:OFF, nil, nil, nil, nil) + @@target_match = new(:TARGET_MATCH, nil, nil, nil, nil) + @@error_instances = { + ERROR_CLIENT_NOT_READY => make_error(ERROR_CLIENT_NOT_READY), + ERROR_FLAG_NOT_FOUND => make_error(ERROR_FLAG_NOT_FOUND), + ERROR_MALFORMED_FLAG => make_error(ERROR_MALFORMED_FLAG), + ERROR_USER_NOT_SPECIFIED => make_error(ERROR_USER_NOT_SPECIFIED), + ERROR_EXCEPTION => make_error(ERROR_EXCEPTION) + } + end +end diff --git a/lib/ldclient-rb/events.rb b/lib/ldclient-rb/events.rb index a5352a0b..2e26e1fa 100644 --- a/lib/ldclient-rb/events.rb +++ b/lib/ldclient-rb/events.rb @@ -238,10 +238,7 @@ def do_shutdown(flush_workers, diagnostic_event_workers) diagnostic_event_workers.shutdown diagnostic_event_workers.wait_for_termination end - begin - @client.finish - rescue - end + @event_sender.stop if @event_sender.respond_to?(:stop) end def synchronize_for_testing(flush_workers, diagnostic_event_workers) diff --git a/lib/ldclient-rb/file_data_source.rb b/lib/ldclient-rb/file_data_source.rb index cfea75f7..f58ddf7c 100644 --- a/lib/ldclient-rb/file_data_source.rb +++ b/lib/ldclient-rb/file_data_source.rb @@ -51,7 +51,7 @@ def self.have_listen? # output as the starting point for your file. In Linux you would do this: # # ``` - # curl -H "Authorization: YOUR_SDK_KEY" https://app.launchdarkly.com/sdk/latest-all + # curl -H "Authorization: YOUR_SDK_KEY" https://sdk.launchdarkly.com/sdk/latest-all # ``` # # The output will look something like this (but with many more properties): diff --git a/lib/ldclient-rb/impl/evaluator.rb b/lib/ldclient-rb/impl/evaluator.rb new file mode 100644 index 00000000..d441eb42 --- /dev/null +++ b/lib/ldclient-rb/impl/evaluator.rb @@ -0,0 +1,225 @@ +require "ldclient-rb/evaluation_detail" +require "ldclient-rb/impl/evaluator_bucketing" +require "ldclient-rb/impl/evaluator_operators" + +module LaunchDarkly + module Impl + # Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment; + # if it needs to retrieve flags or segments that are referenced by a flag, it does so through a simple function that + # is provided in the constructor. It also produces feature requests as appropriate for any referenced prerequisite + # flags, but does not send them. + class Evaluator + # A single Evaluator is instantiated for each client instance. + # + # @param get_flag [Function] called if the Evaluator needs to query a different flag from the one that it is + # currently evaluating (i.e. a prerequisite flag); takes a single parameter, the flag key, and returns the + # flag data - or nil if the flag is unknown or deleted + # @param get_segment [Function] similar to `get_flag`, but is used to query a user segment. + # @param logger [Logger] the client's logger + def initialize(get_flag, get_segment, logger) + @get_flag = get_flag + @get_segment = get_segment + @logger = logger + end + + # Used internally to hold an evaluation result and the events that were generated from prerequisites. The + # `detail` property is an EvaluationDetail. The `events` property can be either an array of feature request + # events or nil. + EvalResult = Struct.new(:detail, :events) + + # Helper function used internally to construct an EvaluationDetail for an error result. + def self.error_result(errorKind, value = nil) + EvaluationDetail.new(value, nil, EvaluationReason.error(errorKind)) + end + + # The client's entry point for evaluating a flag. The returned `EvalResult` contains the evaluation result and + # any events that were generated for prerequisite flags; its `value` will be `nil` if the flag returns the + # default value. Error conditions produce a result with a nil value and an error reason, not an exception. + # + # @param flag [Object] the flag + # @param user [Object] the user properties + # @param event_factory [EventFactory] called to construct a feature request event when a prerequisite flag is + # evaluated; the caller is responsible for constructing the feature event for the top-level evaluation + # @return [EvalResult] the evaluation result + def evaluate(flag, user, event_factory) + if user.nil? || user[:key].nil? + return EvalResult.new(Evaluator.error_result(EvaluationReason::ERROR_USER_NOT_SPECIFIED), []) + end + + # If the flag doesn't have any prerequisites (which most flags don't) then it cannot generate any feature + # request events for prerequisites and we can skip allocating an array. + if flag[:prerequisites] && !flag[:prerequisites].empty? + events = [] + else + events = nil + end + + detail = eval_internal(flag, user, events, event_factory) + return EvalResult.new(detail, events.nil? || events.empty? ? nil : events) + end + + private + + def eval_internal(flag, user, events, event_factory) + if !flag[:on] + return get_off_value(flag, EvaluationReason::off) + end + + prereq_failure_reason = check_prerequisites(flag, user, events, event_factory) + if !prereq_failure_reason.nil? + return get_off_value(flag, prereq_failure_reason) + end + + # 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) + end + end + end + + # Check custom rules + rules = flag[:rules] || [] + rules.each_index do |i| + rule = rules[i] + if rule_match_user(rule, user) + 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) + end + end + + # Check the fallthrough rule + if !flag[:fallthrough].nil? + return get_value_for_variation_or_rollout(flag, flag[:fallthrough], user, EvaluationReason::fallthrough) + end + + return EvaluationDetail.new(nil, nil, EvaluationReason::fallthrough) + end + + def check_prerequisites(flag, user, events, event_factory) + (flag[:prerequisites] || []).each do |prerequisite| + prereq_ok = true + prereq_key = prerequisite[:key] + prereq_flag = @get_flag.call(prereq_key) + + if prereq_flag.nil? + @logger.error { "[LDClient] Could not retrieve prerequisite flag \"#{prereq_key}\" when evaluating \"#{flag[:key]}\"" } + prereq_ok = false + else + begin + prereq_res = eval_internal(prereq_flag, user, events, event_factory) + # Note that if the prerequisite flag is off, we don't consider it a match no matter what its + # off variation was. But we still need to evaluate it in order to generate an event. + if !prereq_flag[:on] || prereq_res.variation_index != prerequisite[:variation] + prereq_ok = false + end + event = event_factory.new_eval_event(prereq_flag, user, prereq_res, nil, flag) + events.push(event) + rescue => exn + Util.log_exception(@logger, "Error evaluating prerequisite flag \"#{prereq_key}\" for flag \"#{flag[:key]}\"", exn) + prereq_ok = false + end + end + if !prereq_ok + reason = prerequisite[:_reason] # try to use cached reason + return reason.nil? ? EvaluationReason::prerequisite_failed(prereq_key) : reason + end + end + nil + end + + def rule_match_user(rule, user) + return false if !rule[:clauses] + + (rule[:clauses] || []).each do |clause| + return false if !clause_match_user(clause, user) + end + + return true + end + + def clause_match_user(clause, user) + # In the case of a segment match operator, we check if the user is in any of the segments, + # and possibly negate + if clause[:op].to_sym == :segmentMatch + result = (clause[:values] || []).any? { |v| + segment = @get_segment.call(v) + !segment.nil? && segment_match_user(segment, user) + } + clause[:negate] ? !result : result + else + clause_match_user_no_segments(clause, user) + end + end + + def clause_match_user_no_segments(clause, user) + user_val = EvaluatorOperators.user_value(user, clause[:attribute]) + return false if user_val.nil? + + op = clause[:op].to_sym + clause_vals = clause[:values] + result = if user_val.is_a? Enumerable + user_val.any? { |uv| clause_vals.any? { |cv| EvaluatorOperators.apply(op, uv, cv) } } + else + clause_vals.any? { |cv| EvaluatorOperators.apply(op, user_val, cv) } + end + clause[:negate] ? !result : result + end + + def segment_match_user(segment, user) + return false unless user[:key] + + return true if segment[:included].include?(user[:key]) + return false if segment[:excluded].include?(user[:key]) + + (segment[:rules] || []).each do |r| + return true if segment_rule_match_user(r, user, segment[:key], segment[:salt]) + end + + return false + end + + def segment_rule_match_user(rule, user, segment_key, salt) + (rule[:clauses] || []).each do |c| + return false unless clause_match_user_no_segments(c, user) + end + + # If the weight is absent, this rule matches + return true if !rule[:weight] + + # All of the clauses are met. See if the user buckets in + bucket = EvaluatorBucketing.bucket_user(user, segment_key, rule[:bucketBy].nil? ? "key" : rule[:bucketBy], salt) + weight = rule[:weight].to_f / 100000.0 + return bucket < weight + 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) + index = EvaluatorBucketing.variation_index_for_user(flag, vr, user) + 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) + end + end + end +end diff --git a/lib/ldclient-rb/impl/evaluator_bucketing.rb b/lib/ldclient-rb/impl/evaluator_bucketing.rb new file mode 100644 index 00000000..b3d14ed1 --- /dev/null +++ b/lib/ldclient-rb/impl/evaluator_bucketing.rb @@ -0,0 +1,74 @@ + +module LaunchDarkly + module Impl + # Encapsulates the logic for percentage rollouts. + module EvaluatorBucketing + # Applies either a fixed variation or a rollout for a rule (or the fallthrough rule). + # + # @param flag [Object] the feature flag + # @param rule [Object] the rule + # @param user [Object] the user properties + # @return [Number] the variation index, or nil if there is an error + def self.variation_index_for_user(flag, rule, user) + variation = rule[:variation] + return variation if !variation.nil? # fixed variation + rollout = rule[:rollout] + return nil if rollout.nil? + variations = rollout[:variations] + if !variations.nil? && variations.length > 0 # percentage rollout + rollout = rule[:rollout] + bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy] + bucket = bucket_user(user, flag[:key], bucket_by, flag[:salt]) + sum = 0; + variations.each do |variate| + sum += variate[:weight].to_f / 100000.0 + if bucket < sum + return variate[:variation] + end + end + # The user's bucket value was greater than or equal to the end of the last bucket. This could happen due + # to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag + # data could contain buckets that don't actually add up to 100000. Rather than returning an error in + # this case (or changing the scaling, which would potentially change the results for *all* users), we + # will simply put the user in the last bucket. + variations[-1][:variation] + else # the rule isn't well-formed + nil + end + end + + # Returns a user's bucket value as a floating-point value in `[0, 1)`. + # + # @param user [Object] the user properties + # @param key [String] the feature flag key (or segment key, if this is for a segment rule) + # @param bucket_by [String|Symbol] the name of the user attribute to be used for bucketing + # @param salt [String] the feature flag's or segment's salt value + # @return [Number] the bucket value, from 0 inclusive to 1 exclusive + def self.bucket_user(user, key, bucket_by, salt) + return nil unless user[:key] + + id_hash = bucketable_string_value(EvaluatorOperators.user_value(user, bucket_by)) + if id_hash.nil? + return 0.0 + end + + if user[:secondary] + id_hash += "." + user[:secondary].to_s + end + + hash_key = "%s.%s.%s" % [key, salt, id_hash] + + hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14] + hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF) + end + + private + + def self.bucketable_string_value(value) + return value if value.is_a? String + return value.to_s if value.is_a? Integer + nil + end + end + end +end diff --git a/lib/ldclient-rb/impl/evaluator_operators.rb b/lib/ldclient-rb/impl/evaluator_operators.rb new file mode 100644 index 00000000..77b0960b --- /dev/null +++ b/lib/ldclient-rb/impl/evaluator_operators.rb @@ -0,0 +1,160 @@ +require "date" +require "semantic" +require "set" + +module LaunchDarkly + module Impl + # Defines the behavior of all operators that can be used in feature flag rules and segment rules. + module EvaluatorOperators + # Applies an operator to produce a boolean result. + # + # @param op [Symbol] one of the supported LaunchDarkly operators, as a symbol + # @param user_value the value of the user attribute that is referenced in the current clause (left-hand + # side of the expression) + # @param clause_value the constant value that `user_value` is being compared to (right-hand side of the + # expression) + # @return [Boolean] true if the expression should be considered a match; false if it is not a match, or + # if the values cannot be compared because they are of the wrong types, or if the operator is unknown + def self.apply(op, user_value, clause_value) + case op + when :in + user_value == clause_value + when :startsWith + string_op(user_value, clause_value, lambda { |a, b| a.start_with? b }) + when :endsWith + string_op(user_value, clause_value, lambda { |a, b| a.end_with? b }) + when :contains + string_op(user_value, clause_value, lambda { |a, b| a.include? b }) + when :matches + string_op(user_value, clause_value, lambda { |a, b| + begin + re = Regexp.new b + !re.match(a).nil? + rescue + false + end + }) + when :lessThan + numeric_op(user_value, clause_value, lambda { |a, b| a < b }) + when :lessThanOrEqual + numeric_op(user_value, clause_value, lambda { |a, b| a <= b }) + when :greaterThan + numeric_op(user_value, clause_value, lambda { |a, b| a > b }) + when :greaterThanOrEqual + numeric_op(user_value, clause_value, lambda { |a, b| a >= b }) + when :before + date_op(user_value, clause_value, lambda { |a, b| a < b }) + when :after + date_op(user_value, clause_value, lambda { |a, b| a > b }) + when :semVerEqual + semver_op(user_value, clause_value, lambda { |a, b| a == b }) + when :semVerLessThan + semver_op(user_value, clause_value, lambda { |a, b| a < b }) + when :semVerGreaterThan + semver_op(user_value, clause_value, lambda { |a, b| a > b }) + when :segmentMatch + # We should never reach this; it can't be evaluated based on just two parameters, because it requires + # looking up the segment from the data store. Instead, we special-case this operator in clause_match_user. + false + else + false + end + end + + # Retrieves the value of a user attribute by name. + # + # Built-in attributes correspond to top-level properties in the user object. They are treated as strings and + # non-string values are coerced to strings, except for `anonymous` which is meant to be a boolean if present + # and is not currently coerced. This behavior is consistent with earlier versions of the Ruby SDK, but is not + # guaranteed to be consistent with other SDKs, since the evaluator specification is based on the strongly-typed + # SDKs where it is not possible for an attribute to have the wrong type. + # + # Custom attributes correspond to properties within the `custom` property, if any, and can be of any type. + # + # @param user [Object] the user properties + # @param attribute [String|Symbol] the attribute to get, for instance `:key` or `:name` or `:some_custom_attr` + # @return the attribute value, or nil if the attribute is unknown + def self.user_value(user, attribute) + attribute = attribute.to_sym + if BUILTINS.include? attribute + value = user[attribute] + return nil if value.nil? + (attribute == :anonymous) ? value : value.to_s + elsif !user[:custom].nil? + user[:custom][attribute] + else + nil + end + end + + private + + BUILTINS = Set[:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous] + NUMERIC_VERSION_COMPONENTS_REGEX = Regexp.new("^[0-9.]*") + + private_constant :BUILTINS + private_constant :NUMERIC_VERSION_COMPONENTS_REGEX + + def self.string_op(user_value, clause_value, fn) + (user_value.is_a? String) && (clause_value.is_a? String) && fn.call(user_value, clause_value) + end + + def self.numeric_op(user_value, clause_value, fn) + (user_value.is_a? Numeric) && (clause_value.is_a? Numeric) && fn.call(user_value, clause_value) + end + + def self.date_op(user_value, clause_value, fn) + ud = to_date(user_value) + if !ud.nil? + cd = to_date(clause_value) + !cd.nil? && fn.call(ud, cd) + else + false + end + end + + def self.semver_op(user_value, clause_value, fn) + uv = to_semver(user_value) + if !uv.nil? + cv = to_semver(clause_value) + !cv.nil? && fn.call(uv, cv) + else + false + end + end + + def self.to_date(value) + if value.is_a? String + begin + DateTime.rfc3339(value).strftime("%Q").to_i + rescue => e + nil + end + elsif value.is_a? Numeric + value + else + nil + end + end + + def self.to_semver(value) + if value.is_a? String + for _ in 0..2 do + begin + return Semantic::Version.new(value) + rescue ArgumentError + value = add_zero_version_component(value) + end + end + end + nil + end + + def self.add_zero_version_component(v) + NUMERIC_VERSION_COMPONENTS_REGEX.match(v) { |m| + m[0] + ".0" + v[m[0].length..-1] + } + end + end + end +end diff --git a/lib/ldclient-rb/impl/event_sender.rb b/lib/ldclient-rb/impl/event_sender.rb index f6da0843..442af033 100644 --- a/lib/ldclient-rb/impl/event_sender.rb +++ b/lib/ldclient-rb/impl/event_sender.rb @@ -1,4 +1,7 @@ +require "ldclient-rb/impl/unbounded_pool" + require "securerandom" +require "http" module LaunchDarkly module Impl @@ -9,62 +12,75 @@ class EventSender DEFAULT_RETRY_INTERVAL = 1 def initialize(sdk_key, config, http_client = nil, retry_interval = DEFAULT_RETRY_INTERVAL) - @client = http_client ? http_client : LaunchDarkly::Util.new_http_client(config.events_uri, config) @sdk_key = sdk_key @config = config @events_uri = config.events_uri + "/bulk" @diagnostic_uri = config.events_uri + "/diagnostic" @logger = config.logger @retry_interval = retry_interval + @http_client_pool = UnboundedPool.new( + lambda { LaunchDarkly::Util.new_http_client(@config.events_uri, @config) }, + lambda { |client| client.close }) + end + + def stop + @http_client_pool.dispose_all() end def send_event_data(event_data, description, is_diagnostic) uri = is_diagnostic ? @diagnostic_uri : @events_uri payload_id = is_diagnostic ? nil : SecureRandom.uuid - res = nil - (0..1).each do |attempt| - if attempt > 0 - @logger.warn { "[LDClient] Will retry posting events after #{@retry_interval} second" } - sleep(@retry_interval) - end - begin - @client.start if !@client.started? - @logger.debug { "[LDClient] sending #{description}: #{event_data}" } - req = Net::HTTP::Post.new(uri) - req.content_type = "application/json" - req.body = event_data - Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| req[k] = v } - if !is_diagnostic - req["X-LaunchDarkly-Event-Schema"] = CURRENT_SCHEMA_VERSION.to_s - req["X-LaunchDarkly-Payload-ID"] = payload_id + begin + http_client = @http_client_pool.acquire() + response = nil + (0..1).each do |attempt| + if attempt > 0 + @logger.warn { "[LDClient] Will retry posting events after #{@retry_interval} second" } + sleep(@retry_interval) end - req["Connection"] = "keep-alive" - res = @client.request(req) - rescue StandardError => exn - @logger.warn { "[LDClient] Error sending events: #{exn.inspect}." } - next - end - status = res.code.to_i - if status >= 200 && status < 300 - res_time = nil - if !res["date"].nil? - begin - res_time = Time.httpdate(res["date"]) - rescue ArgumentError + begin + @logger.debug { "[LDClient] sending #{description}: #{event_data}" } + headers = {} + headers["content-type"] = "application/json" + Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| headers[k] = v } + if !is_diagnostic + headers["X-LaunchDarkly-Event-Schema"] = CURRENT_SCHEMA_VERSION.to_s + headers["X-LaunchDarkly-Payload-ID"] = payload_id end + response = http_client.request("POST", uri, { + headers: headers, + body: event_data + }) + rescue StandardError => exn + @logger.warn { "[LDClient] Error sending events: #{exn.inspect}." } + next + end + status = response.status.code + # must fully read body for persistent connections + body = response.to_s + if status >= 200 && status < 300 + res_time = nil + if !response.headers["date"].nil? + begin + res_time = Time.httpdate(response.headers["date"]) + rescue ArgumentError + end + end + return EventSenderResult.new(true, false, res_time) + end + must_shutdown = !LaunchDarkly::Util.http_error_recoverable?(status) + can_retry = !must_shutdown && attempt == 0 + message = LaunchDarkly::Util.http_error_message(status, "event delivery", can_retry ? "will retry" : "some events were dropped") + @logger.error { "[LDClient] #{message}" } + if must_shutdown + return EventSenderResult.new(false, true, nil) end - return EventSenderResult.new(true, false, res_time) - end - must_shutdown = !LaunchDarkly::Util.http_error_recoverable?(status) - can_retry = !must_shutdown && attempt == 0 - message = LaunchDarkly::Util.http_error_message(status, "event delivery", can_retry ? "will retry" : "some events were dropped") - @logger.error { "[LDClient] #{message}" } - if must_shutdown - return EventSenderResult.new(false, true, nil) end + # used up our retries + return EventSenderResult.new(false, false, nil) + ensure + @http_client_pool.release(http_client) end - # used up our retries - return EventSenderResult.new(false, false, nil) end end end diff --git a/lib/ldclient-rb/impl/integrations/consul_impl.rb b/lib/ldclient-rb/impl/integrations/consul_impl.rb index 10c16dbc..2f186dab 100644 --- a/lib/ldclient-rb/impl/integrations/consul_impl.rb +++ b/lib/ldclient-rb/impl/integrations/consul_impl.rb @@ -39,7 +39,7 @@ def init_internal(all_data) # Insert or update every provided item all_data.each do |kind, items| items.values.each do |item| - value = item.to_json + value = Model.serialize(kind, item) key = item_key(kind, item[:key]) ops.push({ 'KV' => { 'Verb' => 'set', 'Key' => key, 'Value' => value } }) unused_old_keys.delete(key) @@ -62,7 +62,7 @@ def init_internal(all_data) def get_internal(kind, key) value = Diplomat::Kv.get(item_key(kind, key), {}, :return) # :return means "don't throw an error if not found" - (value.nil? || value == "") ? nil : JSON.parse(value, symbolize_names: true) + (value.nil? || value == "") ? nil : Model.deserialize(kind, value) end def get_all_internal(kind) @@ -71,7 +71,7 @@ def get_all_internal(kind) (results == "" ? [] : results).each do |result| value = result[:value] if !value.nil? - item = JSON.parse(value, symbolize_names: true) + item = Model.deserialize(kind, value) items_out[item[:key].to_sym] = item end end @@ -80,7 +80,7 @@ def get_all_internal(kind) def upsert_internal(kind, new_item) key = item_key(kind, new_item[:key]) - json = new_item.to_json + json = Model.serialize(kind, new_item) # We will potentially keep retrying indefinitely until someone's write succeeds while true @@ -88,7 +88,7 @@ def upsert_internal(kind, new_item) if old_value.nil? || old_value == "" mod_index = 0 else - old_item = JSON.parse(old_value[0]["Value"], symbolize_names: true) + old_item = Model.deserialize(kind, old_value[0]["Value"]) # Check whether the item is stale. If so, don't do the update (and return the existing item to # FeatureStoreWrapper so it can be cached) if old_item[:version] >= new_item[:version] diff --git a/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb b/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb index a76fae52..464eb5e4 100644 --- a/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +++ b/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb @@ -77,7 +77,7 @@ def init_internal(all_data) def get_internal(kind, key) resp = get_item_by_keys(namespace_for_kind(kind), key) - unmarshal_item(resp.item) + unmarshal_item(kind, resp.item) end def get_all_internal(kind) @@ -86,7 +86,7 @@ def get_all_internal(kind) while true resp = @client.query(req) resp.items.each do |item| - item_out = unmarshal_item(item) + item_out = unmarshal_item(kind, item) items_out[item_out[:key].to_sym] = item_out end break if resp.last_evaluated_key.nil? || resp.last_evaluated_key.length == 0 @@ -196,15 +196,15 @@ def read_existing_keys(kinds) def marshal_item(kind, item) make_keys_hash(namespace_for_kind(kind), item[:key]).merge({ VERSION_ATTRIBUTE => item[:version], - ITEM_JSON_ATTRIBUTE => item.to_json + ITEM_JSON_ATTRIBUTE => Model.serialize(kind, item) }) end - def unmarshal_item(item) + def unmarshal_item(kind, item) return nil if item.nil? || item.length == 0 json_attr = item[ITEM_JSON_ATTRIBUTE] raise RuntimeError.new("DynamoDB map did not contain expected item string") if json_attr.nil? - JSON.parse(json_attr, symbolize_names: true) + Model.deserialize(kind, json_attr) end end diff --git a/lib/ldclient-rb/impl/integrations/redis_impl.rb b/lib/ldclient-rb/impl/integrations/redis_impl.rb index 0e432b41..f948e54a 100644 --- a/lib/ldclient-rb/impl/integrations/redis_impl.rb +++ b/lib/ldclient-rb/impl/integrations/redis_impl.rb @@ -55,7 +55,7 @@ def init_internal(all_data) multi.del(items_key(kind)) count = count + items.count items.each do |key, item| - multi.hset(items_key(kind), key, item.to_json) + multi.hset(items_key(kind), key, Model.serialize(kind,item)) end end multi.set(inited_key, inited_key) @@ -75,8 +75,7 @@ def get_all_internal(kind) with_connection do |redis| hashfs = redis.hgetall(items_key(kind)) hashfs.each do |k, json_item| - f = JSON.parse(json_item, symbolize_names: true) - fs[k.to_sym] = f + fs[k.to_sym] = Model.deserialize(kind, json_item) end end fs @@ -95,7 +94,7 @@ def upsert_internal(kind, new_item) before_update_transaction(base_key, key) if old_item.nil? || old_item[:version] < new_item[:version] result = redis.multi do |multi| - multi.hset(base_key, key, new_item.to_json) + multi.hset(base_key, key, Model.serialize(kind, new_item)) end if result.nil? @logger.debug { "RedisFeatureStore: concurrent modification detected, retrying" } @@ -115,9 +114,7 @@ def upsert_internal(kind, new_item) end def initialized_internal? - with_connection do |redis| - redis.respond_to?(:exists?) ? redis.exists?(inited_key) : redis.exists(inited_key) - end + with_connection { |redis| redis.exists?(inited_key) } end def stop @@ -150,8 +147,7 @@ def with_connection end def get_redis(redis, kind, key) - json_item = redis.hget(items_key(kind), key) - json_item.nil? ? nil : JSON.parse(json_item, symbolize_names: true) + Model.deserialize(kind, redis.hget(items_key(kind), key)) end end end diff --git a/lib/ldclient-rb/impl/model/serialization.rb b/lib/ldclient-rb/impl/model/serialization.rb new file mode 100644 index 00000000..fcf8b135 --- /dev/null +++ b/lib/ldclient-rb/impl/model/serialization.rb @@ -0,0 +1,62 @@ + +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) + return nil if json.nil? + item = JSON.parse(json, symbolize_names: true) + postprocess_item_after_deserializing!(kind, item) + item + end + + # Abstraction of serializing a feature flag or segment that will be written to a data store. + # Currently we just call to_json. + def self.serialize(kind, item) + item.to_json + end + + # Translates a { flags: ..., segments: ... } object received from LaunchDarkly to the data store format. + def self.make_all_store_data(received_data) + flags = received_data[:flags] + postprocess_items_after_deserializing!(FEATURES, flags) + segments = received_data[:segments] + postprocess_items_after_deserializing!(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/unbounded_pool.rb b/lib/ldclient-rb/impl/unbounded_pool.rb new file mode 100644 index 00000000..55bd515f --- /dev/null +++ b/lib/ldclient-rb/impl/unbounded_pool.rb @@ -0,0 +1,34 @@ +module LaunchDarkly + module Impl + # A simple thread safe generic unbounded resource pool abstraction + class UnboundedPool + def initialize(instance_creator, instance_destructor) + @pool = Array.new + @lock = Mutex.new + @instance_creator = instance_creator + @instance_destructor = instance_destructor + end + + def acquire + @lock.synchronize { + if @pool.length == 0 + @instance_creator.call() + else + @pool.pop() + end + } + end + + def release(instance) + @lock.synchronize { @pool.push(instance) } + end + + def dispose_all + @lock.synchronize { + @pool.map { |instance| @instance_destructor.call(instance) } if !@instance_destructor.nil? + @pool.clear() + } + end + end + end +end \ No newline at end of file diff --git a/lib/ldclient-rb/ldclient.rb b/lib/ldclient-rb/ldclient.rb index 7ea48345..cfa63351 100644 --- a/lib/ldclient-rb/ldclient.rb +++ b/lib/ldclient-rb/ldclient.rb @@ -1,4 +1,5 @@ require "ldclient-rb/impl/diagnostic_events" +require "ldclient-rb/impl/evaluator" require "ldclient-rb/impl/event_factory" require "ldclient-rb/impl/store_client_wrapper" require "concurrent/atomics" @@ -14,7 +15,6 @@ module LaunchDarkly # should create a single client instance for the lifetime of the application. # class LDClient - include Evaluation include Impl # # Creates a new client instance that connects to LaunchDarkly. A custom @@ -57,6 +57,10 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5) updated_config.instance_variable_set(:@feature_store, @store) @config = updated_config + get_flag = lambda { |key| @store.get(FEATURES, key) } + get_segment = lambda { |key| @store.get(SEGMENTS, key) } + @evaluator = LaunchDarkly::Impl::Evaluator.new(get_flag, get_segment, @config.logger) + if !@config.offline? && @config.send_events && !@config.diagnostic_opt_out? diagnostic_accumulator = Impl::DiagnosticAccumulator.new(Impl::DiagnosticAccumulator.create_diagnostic_id(sdk_key)) else @@ -333,12 +337,13 @@ def all_flags_state(user, options={}) next end begin - result = evaluate(f, user, @store, @config.logger, @event_factory_default) + result = @evaluator.evaluate(f, user, @event_factory_default) state.add_flag(f, result.detail.value, result.detail.variation_index, with_reasons ? result.detail.reason : nil, details_only_if_tracked) rescue => exn Util.log_exception(@config.logger, "Error evaluating flag \"#{k}\" in all_flags_state", exn) - state.add_flag(f, nil, nil, with_reasons ? { kind: 'ERROR', errorKind: 'EXCEPTION' } : nil, details_only_if_tracked) + state.add_flag(f, nil, nil, with_reasons ? EvaluationReason::error(EvaluationReason::ERROR_EXCEPTION) : nil, + details_only_if_tracked) end end @@ -376,7 +381,7 @@ def create_default_data_source(sdk_key, config, diagnostic_accumulator) # @return [EvaluationDetail] def evaluate_internal(key, user, default, event_factory) if @config.offline? - return error_result('CLIENT_NOT_READY', default) + return Evaluator.error_result(EvaluationReason::ERROR_CLIENT_NOT_READY, default) end if !initialized? @@ -384,7 +389,7 @@ def evaluate_internal(key, user, default, event_factory) @config.logger.warn { "[LDClient] Client has not finished initializing; using last known values from feature store" } else @config.logger.error { "[LDClient] Client has not finished initializing; feature store unavailable, returning default value" } - detail = error_result('CLIENT_NOT_READY', default) + detail = Evaluator.error_result(EvaluationReason::ERROR_CLIENT_NOT_READY, default) @event_processor.add_event(event_factory.new_unknown_flag_event(key, user, default, detail.reason)) return detail end @@ -394,20 +399,20 @@ def evaluate_internal(key, user, default, event_factory) if feature.nil? @config.logger.info { "[LDClient] Unknown feature flag \"#{key}\". Returning default value" } - detail = error_result('FLAG_NOT_FOUND', default) + detail = Evaluator.error_result(EvaluationReason::ERROR_FLAG_NOT_FOUND, default) @event_processor.add_event(event_factory.new_unknown_flag_event(key, user, default, detail.reason)) return detail end unless user @config.logger.error { "[LDClient] Must specify user" } - detail = error_result('USER_NOT_SPECIFIED', default) + detail = Evaluator.error_result(EvaluationReason::ERROR_USER_NOT_SPECIFIED, default) @event_processor.add_event(event_factory.new_default_event(feature, user, default, detail.reason)) return detail end begin - res = evaluate(feature, user, @store, @config.logger, event_factory) + res = @evaluator.evaluate(feature, user, event_factory) if !res.events.nil? res.events.each do |event| @event_processor.add_event(event) @@ -421,7 +426,7 @@ def evaluate_internal(key, user, default, event_factory) return detail rescue => exn Util.log_exception(@config.logger, "Error evaluating feature flag \"#{key}\"", exn) - detail = error_result('EXCEPTION', default) + detail = Evaluator.error_result(EvaluationReason::ERROR_EXCEPTION, default) @event_processor.add_event(event_factory.new_default_event(feature, user, default, detail.reason)) return detail end diff --git a/lib/ldclient-rb/polling.rb b/lib/ldclient-rb/polling.rb index da0427dc..a9312413 100644 --- a/lib/ldclient-rb/polling.rb +++ b/lib/ldclient-rb/polling.rb @@ -37,10 +37,7 @@ def stop def poll all_data = @requestor.request_all_data if all_data - @config.feature_store.init({ - FEATURES => all_data[:flags], - SEGMENTS => all_data[:segments] - }) + @config.feature_store.init(all_data) if @initialized.make_true @config.logger.info { "[LDClient] Polling connection initialized" } @ready.set diff --git a/lib/ldclient-rb/requestor.rb b/lib/ldclient-rb/requestor.rb index 378a1a35..35c5e365 100644 --- a/lib/ldclient-rb/requestor.rb +++ b/lib/ldclient-rb/requestor.rb @@ -1,6 +1,9 @@ +require "ldclient-rb/impl/model/serialization" + require "concurrent/atomics" require "json" require "uri" +require "http" module LaunchDarkly # @private @@ -22,37 +25,44 @@ class Requestor def initialize(sdk_key, config) @sdk_key = sdk_key @config = config - @client = Util.new_http_client(@config.base_uri, @config) + @http_client = LaunchDarkly::Util.new_http_client(config.base_uri, config) @cache = @config.cache_store end def request_all_data() - make_request("/sdk/latest-all") + all_data = JSON.parse(make_request("/sdk/latest-all"), symbolize_names: true) + Impl::Model.make_all_store_data(all_data) end def stop begin - @client.finish + @http_client.close rescue end end private + def request_single_item(kind, path) + Impl::Model.deserialize(kind, make_request(path)) + end + def make_request(path) - @client.start if !@client.started? uri = URI(@config.base_uri + path) - req = Net::HTTP::Get.new(uri) - Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| req[k] = v } - req["Connection"] = "keep-alive" + headers = {} + Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| headers[k] = v } + headers["Connection"] = "keep-alive" cached = @cache.read(uri) if !cached.nil? - req["If-None-Match"] = cached.etag + headers["If-None-Match"] = cached.etag end - res = @client.request(req) - status = res.code.to_i - @config.logger.debug { "[LDClient] Got response from uri: #{uri}\n\tstatus code: #{status}\n\theaders: #{res.to_hash}\n\tbody: #{res.body}" } - + response = @http_client.request("GET", uri, { + headers: headers + }) + status = response.status.code + @config.logger.debug { "[LDClient] Got response from uri: #{uri}\n\tstatus code: #{status}\n\theaders: #{response.headers}\n\tbody: #{res.to_s}" } + # must fully read body for persistent connections + body = response.to_s if status == 304 && !cached.nil? body = cached.body else @@ -60,11 +70,11 @@ def make_request(path) if status < 200 || status >= 300 raise UnexpectedResponseError.new(status) end - body = fix_encoding(res.body, res["content-type"]) - etag = res["etag"] + body = fix_encoding(body, response.headers["content-type"]) + etag = response.headers["etag"] @cache.write(uri, CacheEntry.new(etag, body)) if !etag.nil? end - JSON.parse(body, symbolize_names: true) + body end def fix_encoding(body, content_type) diff --git a/lib/ldclient-rb/stream.rb b/lib/ldclient-rb/stream.rb index 00791eb3..64275b39 100644 --- a/lib/ldclient-rb/stream.rb +++ b/lib/ldclient-rb/stream.rb @@ -1,3 +1,5 @@ +require "ldclient-rb/impl/model/serialization" + require "concurrent/atomics" require "json" require "ld-eventsource" @@ -44,7 +46,8 @@ def start opts = { headers: headers, read_timeout: READ_TIMEOUT_SECONDS, - logger: @config.logger + logger: @config.logger, + socket_factory: @config.socket_factory } log_connection_started @es = SSE::Client.new(@config.stream_uri + "/all", **opts) do |conn| @@ -82,10 +85,8 @@ 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) - @feature_store.init({ - FEATURES => message[:data][:flags], - SEGMENTS => message[:data][:segments] - }) + all_data = Impl::Model.make_all_store_data(message[:data]) + @feature_store.init(all_data) @initialized.make_true @config.logger.info { "[LDClient] Stream initialized" } @ready.set @@ -94,7 +95,9 @@ def process_message(message) for kind in [FEATURES, SEGMENTS] key = key_for_path(kind, data[:path]) if key - @feature_store.upsert(kind, data[:data]) + data = data[:data] + Impl::Model.postprocess_item_after_deserializing!(kind, data) + @feature_store.upsert(kind, data) break end end diff --git a/lib/ldclient-rb/util.rb b/lib/ldclient-rb/util.rb index e129c279..cfd09d8d 100644 --- a/lib/ldclient-rb/util.rb +++ b/lib/ldclient-rb/util.rb @@ -1,5 +1,5 @@ -require "net/http" require "uri" +require "http" module LaunchDarkly # @private @@ -18,14 +18,18 @@ def self.stringify_attrs(hash, attrs) end ret end - + def self.new_http_client(uri_s, config) - uri = URI(uri_s) - client = Net::HTTP.new(uri.hostname, uri.port) - client.use_ssl = true if uri.scheme == "https" - client.open_timeout = config.connect_timeout - client.read_timeout = config.read_timeout - client + http_client_options = {} + if config.socket_factory + http_client_options["socket_class"] = config.socket_factory + end + return HTTP::Client.new(http_client_options) + .timeout({ + read: config.read_timeout, + connect: config.connect_timeout + }) + .persistent(uri_s) end def self.log_exception(logger, message, exc) diff --git a/spec/evaluation_detail_spec.rb b/spec/evaluation_detail_spec.rb new file mode 100644 index 00000000..3d7418ed --- /dev/null +++ b/spec/evaluation_detail_spec.rb @@ -0,0 +1,135 @@ +require "spec_helper" + +module LaunchDarkly + describe "EvaluationDetail" do + subject { EvaluationDetail } + + it "sets properties" do + expect(EvaluationDetail.new("x", 0, EvaluationReason::off).value).to eq "x" + expect(EvaluationDetail.new("x", 0, EvaluationReason::off).variation_index).to eq 0 + expect(EvaluationDetail.new("x", 0, EvaluationReason::off).reason).to eq EvaluationReason::off + end + + it "checks parameter types" do + expect { EvaluationDetail.new(nil, nil, EvaluationReason::off) }.not_to raise_error + expect { EvaluationDetail.new(nil, 0, EvaluationReason::off) }.not_to raise_error + expect { EvaluationDetail.new(nil, "x", EvaluationReason::off) }.to raise_error(ArgumentError) + expect { EvaluationDetail.new(nil, 0, { kind: "OFF" }) }.to raise_error(ArgumentError) + expect { EvaluationDetail.new(nil, 0, nil) }.to raise_error(ArgumentError) + end + + it "equality test" do + expect(EvaluationDetail.new("x", 0, EvaluationReason::off)).to eq EvaluationDetail.new("x", 0, EvaluationReason::off) + expect(EvaluationDetail.new("x", 0, EvaluationReason::off)).not_to eq EvaluationDetail.new("y", 0, EvaluationReason::off) + expect(EvaluationDetail.new("x", 0, EvaluationReason::off)).not_to eq EvaluationDetail.new("x", 1, EvaluationReason::off) + expect(EvaluationDetail.new("x", 0, EvaluationReason::off)).not_to eq EvaluationDetail.new("x", 0, EvaluationReason::fallthrough) + end + end + + describe "EvaluationReason" do + subject { EvaluationReason } + + values = [ + [ EvaluationReason::off, EvaluationReason::OFF, { "kind" => "OFF" }, "OFF", nil ], + [ EvaluationReason::fallthrough, EvaluationReason::FALLTHROUGH, + { "kind" => "FALLTHROUGH" }, "FALLTHROUGH", nil ], + [ EvaluationReason::target_match, EvaluationReason::TARGET_MATCH, + { "kind" => "TARGET_MATCH" }, "TARGET_MATCH", nil ], + [ EvaluationReason::rule_match(1, "x"), EvaluationReason::RULE_MATCH, + { "kind" => "RULE_MATCH", "ruleIndex" => 1, "ruleId" => "x" }, "RULE_MATCH(1,x)", + [ EvaluationReason::rule_match(2, "x"), EvaluationReason::rule_match(1, "y") ] ], + [ EvaluationReason::prerequisite_failed("x"), EvaluationReason::PREREQUISITE_FAILED, + { "kind" => "PREREQUISITE_FAILED", "prerequisiteKey" => "x" }, "PREREQUISITE_FAILED(x)" ], + [ EvaluationReason::error(EvaluationReason::ERROR_FLAG_NOT_FOUND), EvaluationReason::ERROR, + { "kind" => "ERROR", "errorKind" => "FLAG_NOT_FOUND" }, "ERROR(FLAG_NOT_FOUND)" ] + ] + values.each_index do |i| + params = values[i] + reason = params[0] + kind = params[1] + json_rep = params[2] + brief_str = params[3] + unequal_values = params[4] + + describe "reason #{reason.kind}" do + it "has correct kind" do + expect(reason.kind).to eq kind + end + + it "equality to self" do + expect(reason).to eq reason + end + + it "inequality to others" do + values.each_index do |j| + if i != j + expect(reason).not_to eq values[j][0] + end + end + if !unequal_values.nil? + unequal_values.each do |v| + expect(reason).not_to eq v + end + end + end + + it "JSON representation" do + expect(JSON.parse(reason.as_json.to_json)).to eq json_rep + expect(JSON.parse(reason.to_json)).to eq json_rep + end + + it "brief representation" do + expect(reason.inspect).to eq brief_str + expect(reason.to_s).to eq brief_str + end + end + end + + it "reuses singleton reasons" do + expect(EvaluationReason::off).to be EvaluationReason::off + expect(EvaluationReason::fallthrough).to be EvaluationReason::fallthrough + expect(EvaluationReason::target_match).to be EvaluationReason::target_match + expect(EvaluationReason::rule_match(1, 'x')).not_to be EvaluationReason::rule_match(1, 'x') + expect(EvaluationReason::prerequisite_failed('x')).not_to be EvaluationReason::prerequisite_failed('x') + errors = [ EvaluationReason::ERROR_CLIENT_NOT_READY, EvaluationReason::ERROR_FLAG_NOT_FOUND, + EvaluationReason::ERROR_MALFORMED_FLAG, EvaluationReason::ERROR_USER_NOT_SPECIFIED, EvaluationReason::ERROR_EXCEPTION ] + errors.each do |e| + expect(EvaluationReason::error(e)).to be EvaluationReason::error(e) + end + end + + it "supports [] with JSON property names" do + expect(EvaluationReason::off[:kind]).to eq "OFF" + expect(EvaluationReason::off[:ruleIndex]).to be nil + expect(EvaluationReason::off[:ruleId]).to be nil + expect(EvaluationReason::off[:prerequisiteKey]).to be nil + expect(EvaluationReason::off[:errorKind]).to be nil + expect(EvaluationReason::rule_match(1, "x")[:ruleIndex]).to eq 1 + expect(EvaluationReason::rule_match(1, "x")[:ruleId]).to eq "x" + expect(EvaluationReason::prerequisite_failed("x")[:prerequisiteKey]).to eq "x" + expect(EvaluationReason::error(EvaluationReason::ERROR_FLAG_NOT_FOUND)[:errorKind]).to eq "FLAG_NOT_FOUND" + end + + it "freezes string properties" do + rm = EvaluationReason::rule_match(1, "x") + expect { rm.rule_id.upcase! }.to raise_error(RuntimeError) + pf = EvaluationReason::prerequisite_failed("x") + expect { pf.prerequisite_key.upcase! }.to raise_error(RuntimeError) + end + + it "checks parameter types" do + expect { EvaluationReason::rule_match(nil, "x") }.to raise_error(ArgumentError) + expect { EvaluationReason::rule_match(true, "x") }.to raise_error(ArgumentError) + expect { EvaluationReason::rule_match(1, nil) }.not_to raise_error # we allow nil rule_id for backward compatibility + expect { EvaluationReason::rule_match(1, 9) }.to raise_error(ArgumentError) + expect { EvaluationReason::prerequisite_failed(nil) }.to raise_error(ArgumentError) + expect { EvaluationReason::prerequisite_failed(9) }.to raise_error(ArgumentError) + expect { EvaluationReason::error(nil) }.to raise_error(ArgumentError) + expect { EvaluationReason::error(9) }.to raise_error(ArgumentError) + end + + it "does not allow direct access to constructor" do + expect { EvaluationReason.new(:off, nil, nil, nil, nil) }.to raise_error(NoMethodError) + end + end +end diff --git a/spec/evaluation_spec.rb b/spec/evaluation_spec.rb deleted file mode 100644 index b8bed817..00000000 --- a/spec/evaluation_spec.rb +++ /dev/null @@ -1,789 +0,0 @@ -require "spec_helper" - -describe LaunchDarkly::Evaluation do - subject { LaunchDarkly::Evaluation } - - include LaunchDarkly::Evaluation - - let(:features) { LaunchDarkly::InMemoryFeatureStore.new } - - let(:factory) { LaunchDarkly::Impl::EventFactory.new(false) } - - let(:user) { - { - key: "userkey", - email: "test@example.com", - name: "Bob" - } - } - - let(:logger) { $null_log } - - 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 - - describe "evaluate" do - it "returns off variation if flag is off" do - flag = { - key: 'feature', - on: false, - offVariation: 1, - fallthrough: { variation: 0 }, - variations: ['a', 'b', 'c'] - } - user = { key: 'x' } - detail = LaunchDarkly::EvaluationDetail.new('b', 1, { kind: 'OFF' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - end - - it "returns nil if flag is off and off variation is unspecified" do - flag = { - key: 'feature', - on: false, - fallthrough: { variation: 0 }, - variations: ['a', 'b', 'c'] - } - user = { key: 'x' } - detail = LaunchDarkly::EvaluationDetail.new(nil, nil, { kind: 'OFF' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - end - - it "returns an error if off variation is too high" do - flag = { - key: 'feature', - on: false, - offVariation: 999, - fallthrough: { variation: 0 }, - variations: ['a', 'b', 'c'] - } - user = { key: 'x' } - detail = LaunchDarkly::EvaluationDetail.new(nil, nil, - { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - end - - it "returns an error if off variation is negative" do - flag = { - key: 'feature', - on: false, - offVariation: -1, - fallthrough: { variation: 0 }, - variations: ['a', 'b', 'c'] - } - user = { key: 'x' } - detail = LaunchDarkly::EvaluationDetail.new(nil, nil, - { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - end - - it "returns off variation if prerequisite is not found" do - flag = { - key: 'feature0', - on: true, - prerequisites: [{key: 'badfeature', variation: 1}], - fallthrough: { variation: 0 }, - offVariation: 1, - variations: ['a', 'b', 'c'] - } - user = { key: 'x' } - detail = LaunchDarkly::EvaluationDetail.new('b', 1, - { kind: 'PREREQUISITE_FAILED', prerequisiteKey: 'badfeature' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - end - - it "returns off variation and event if prerequisite of a prerequisite is not found" do - flag = { - key: 'feature0', - on: true, - prerequisites: [{key: 'feature1', variation: 1}], - fallthrough: { variation: 0 }, - offVariation: 1, - variations: ['a', 'b', 'c'], - version: 1 - } - flag1 = { - key: 'feature1', - on: true, - prerequisites: [{key: 'feature2', variation: 1}], # feature2 doesn't exist - fallthrough: { variation: 0 }, - variations: ['d', 'e'], - version: 2 - } - features.upsert(LaunchDarkly::FEATURES, flag1) - user = { key: 'x' } - detail = LaunchDarkly::EvaluationDetail.new('b', 1, - { kind: 'PREREQUISITE_FAILED', prerequisiteKey: 'feature1' }) - events_should_be = [{ - kind: 'feature', key: 'feature1', user: user, value: nil, default: nil, variation: nil, version: 2, prereqOf: 'feature0' - }] - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq(events_should_be) - end - - it "returns off variation and event if prerequisite is off" do - flag = { - key: 'feature0', - on: true, - prerequisites: [{key: 'feature1', variation: 1}], - fallthrough: { variation: 0 }, - offVariation: 1, - variations: ['a', 'b', 'c'], - version: 1 - } - flag1 = { - key: 'feature1', - on: false, - # note that even though it returns the desired variation, it is still off and therefore not a match - offVariation: 1, - fallthrough: { variation: 0 }, - variations: ['d', 'e'], - version: 2 - } - features.upsert(LaunchDarkly::FEATURES, flag1) - user = { key: 'x' } - detail = LaunchDarkly::EvaluationDetail.new('b', 1, - { kind: 'PREREQUISITE_FAILED', prerequisiteKey: 'feature1' }) - events_should_be = [{ - kind: 'feature', key: 'feature1', user: user, variation: 1, value: 'e', default: nil, version: 2, prereqOf: 'feature0' - }] - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq(events_should_be) - end - - it "returns off variation and event if prerequisite is not met" do - flag = { - key: 'feature0', - on: true, - prerequisites: [{key: 'feature1', variation: 1}], - fallthrough: { variation: 0 }, - offVariation: 1, - variations: ['a', 'b', 'c'], - version: 1 - } - flag1 = { - key: 'feature1', - on: true, - fallthrough: { variation: 0 }, - variations: ['d', 'e'], - version: 2 - } - features.upsert(LaunchDarkly::FEATURES, flag1) - user = { key: 'x' } - detail = LaunchDarkly::EvaluationDetail.new('b', 1, - { kind: 'PREREQUISITE_FAILED', prerequisiteKey: 'feature1' }) - events_should_be = [{ - kind: 'feature', key: 'feature1', user: user, variation: 0, value: 'd', default: nil, version: 2, prereqOf: 'feature0' - }] - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq(events_should_be) - end - - it "returns fallthrough variation and event if prerequisite is met and there are no rules" do - flag = { - key: 'feature0', - on: true, - prerequisites: [{key: 'feature1', variation: 1}], - fallthrough: { variation: 0 }, - offVariation: 1, - variations: ['a', 'b', 'c'], - version: 1 - } - flag1 = { - key: 'feature1', - on: true, - fallthrough: { variation: 1 }, - variations: ['d', 'e'], - version: 2 - } - features.upsert(LaunchDarkly::FEATURES, flag1) - user = { key: 'x' } - detail = LaunchDarkly::EvaluationDetail.new('a', 0, { kind: 'FALLTHROUGH' }) - events_should_be = [{ - kind: 'feature', key: 'feature1', user: user, variation: 1, value: 'e', default: nil, version: 2, prereqOf: 'feature0' - }] - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq(events_should_be) - end - - it "returns an error if fallthrough variation is too high" do - flag = { - key: 'feature', - on: true, - fallthrough: { variation: 999 }, - offVariation: 1, - variations: ['a', 'b', 'c'] - } - user = { key: 'userkey' } - detail = LaunchDarkly::EvaluationDetail.new(nil, nil, { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - end - - it "returns an error if fallthrough variation is negative" do - flag = { - key: 'feature', - on: true, - fallthrough: { variation: -1 }, - offVariation: 1, - variations: ['a', 'b', 'c'] - } - user = { key: 'userkey' } - detail = LaunchDarkly::EvaluationDetail.new(nil, nil, { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - end - - it "returns an error if fallthrough has no variation or rollout" do - flag = { - key: 'feature', - on: true, - fallthrough: { }, - offVariation: 1, - variations: ['a', 'b', 'c'] - } - user = { key: 'userkey' } - detail = LaunchDarkly::EvaluationDetail.new(nil, nil, { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - end - - it "returns an error if fallthrough has a rollout with no variations" do - flag = { - key: 'feature', - on: true, - fallthrough: { rollout: { variations: [] } }, - offVariation: 1, - variations: ['a', 'b', 'c'] - } - user = { key: 'userkey' } - detail = LaunchDarkly::EvaluationDetail.new(nil, nil, { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - end - - it "matches user from targets" do - flag = { - key: 'feature', - on: true, - targets: [ - { values: [ 'whoever', 'userkey' ], variation: 2 } - ], - fallthrough: { variation: 0 }, - offVariation: 1, - variations: ['a', 'b', 'c'] - } - user = { key: 'userkey' } - detail = LaunchDarkly::EvaluationDetail.new('c', 2, { kind: 'TARGET_MATCH' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - end - - 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 = LaunchDarkly::EvaluationDetail.new(true, 1, - { kind: 'RULE_MATCH', ruleIndex: 0, ruleId: 'ruleid' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - 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 = LaunchDarkly::EvaluationDetail.new(nil, nil, - { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - 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 = LaunchDarkly::EvaluationDetail.new(nil, nil, - { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - 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 = LaunchDarkly::EvaluationDetail.new(nil, nil, - { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - 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 = LaunchDarkly::EvaluationDetail.new(nil, nil, - { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }) - result = evaluate(flag, user, features, logger, factory) - expect(result.detail).to eq(detail) - expect(result.events).to eq([]) - 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 = evaluate(flag, user, features, logger, factory) - expect(result.detail.value).to eq(true) - 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 = evaluate(flag, user, features, logger, factory) - expect(result.detail.reason).to eq({ kind: 'RULE_MATCH', ruleIndex: 0, ruleId: 'ruleid'}) - end - end - - describe "clause" do - 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(evaluate(flag, user, features, logger, factory).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(evaluate(flag, user, features, logger, factory).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(evaluate(flag, user, features, logger, factory).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(evaluate(flag, user, features, logger, factory).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(evaluate(flag, user, features, logger, factory).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(evaluate(flag, user, features, logger, factory).detail.value).to be false - end - - it "retrieves segment from segment store for segmentMatch operator" do - segment = { - key: 'segkey', - included: [ 'userkey' ], - version: 1, - deleted: false - } - features.upsert(LaunchDarkly::SEGMENTS, segment) - - user = { key: 'userkey' } - clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] } - flag = boolean_flag_with_clauses([clause]) - expect(evaluate(flag, user, features, logger, factory).detail.value).to be true - end - - it "falls through with no errors if referenced segment is not found" do - user = { key: 'userkey' } - clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] } - flag = boolean_flag_with_clauses([clause]) - expect(evaluate(flag, user, features, logger, factory).detail.value).to be false - end - - it "can be negated" do - user = { key: 'x', name: 'Bob' } - clause = { attribute: 'name', op: 'in', values: ['Bob'] } - flag = boolean_flag_with_clauses([clause]) - expect { - clause[:negate] = true - }.to change {evaluate(flag, user, features, logger, factory).detail.value}.from(true).to(false) - end - end - - describe "operators" do - dateStr1 = "2017-12-06T00:00:00.000-07:00" - dateStr2 = "2017-12-06T00:01:01.000-07:00" - dateMs1 = 10000000 - dateMs2 = 10000001 - invalidDate = "hey what's this?" - - operatorTests = [ - # numeric comparisons - [ :in, 99, 99, true ], - [ :in, 99.0001, 99.0001, true ], - [ :in, 99, 99.0001, false ], - [ :in, 99.0001, 99, false ], - [ :lessThan, 99, 99.0001, true ], - [ :lessThan, 99.0001, 99, false ], - [ :lessThan, 99, 99, false ], - [ :lessThanOrEqual, 99, 99.0001, true ], - [ :lessThanOrEqual, 99.0001, 99, false ], - [ :lessThanOrEqual, 99, 99, true ], - [ :greaterThan, 99.0001, 99, true ], - [ :greaterThan, 99, 99.0001, false ], - [ :greaterThan, 99, 99, false ], - [ :greaterThanOrEqual, 99.0001, 99, true ], - [ :greaterThanOrEqual, 99, 99.0001, false ], - [ :greaterThanOrEqual, 99, 99, true ], - - # string comparisons - [ :in, "x", "x", true ], - [ :in, "x", "xyz", false ], - [ :startsWith, "xyz", "x", true ], - [ :startsWith, "x", "xyz", false ], - [ :endsWith, "xyz", "z", true ], - [ :endsWith, "z", "xyz", false ], - [ :contains, "xyz", "y", true ], - [ :contains, "y", "xyz", false ], - - # mixed strings and numbers - [ :in, "99", 99, false ], - [ :in, 99, "99", false ], - [ :contains, "99", 99, false ], - [ :startsWith, "99", 99, false ], - [ :endsWith, "99", 99, false ], - [ :lessThanOrEqual, "99", 99, false ], - [ :lessThanOrEqual, 99, "99", false ], - [ :greaterThanOrEqual, "99", 99, false ], - [ :greaterThanOrEqual, 99, "99", false ], - - # regex - [ :matches, "hello world", "hello.*rld", true ], - [ :matches, "hello world", "hello.*orl", true ], - [ :matches, "hello world", "l+", true ], - [ :matches, "hello world", "(world|planet)", true ], - [ :matches, "hello world", "aloha", false ], - [ :matches, "hello world", "***not a regex", false ], - - # dates - [ :before, dateStr1, dateStr2, true ], - [ :before, dateMs1, dateMs2, true ], - [ :before, dateStr2, dateStr1, false ], - [ :before, dateMs2, dateMs1, false ], - [ :before, dateStr1, dateStr1, false ], - [ :before, dateMs1, dateMs1, false ], - [ :before, dateStr1, invalidDate, false ], - [ :after, dateStr1, dateStr2, false ], - [ :after, dateMs1, dateMs2, false ], - [ :after, dateStr2, dateStr1, true ], - [ :after, dateMs2, dateMs1, true ], - [ :after, dateStr1, dateStr1, false ], - [ :after, dateMs1, dateMs1, false ], - [ :after, dateStr1, invalidDate, false ], - - # semver - [ :semVerEqual, "2.0.1", "2.0.1", true ], - [ :semVerEqual, "2.0", "2.0.0", true ], - [ :semVerEqual, "2-rc1", "2.0.0-rc1", true ], - [ :semVerEqual, "2+build2", "2.0.0+build2", true ], - [ :semVerLessThan, "2.0.0", "2.0.1", true ], - [ :semVerLessThan, "2.0", "2.0.1", true ], - [ :semVerLessThan, "2.0.1", "2.0.0", false ], - [ :semVerLessThan, "2.0.1", "2.0", false ], - [ :semVerLessThan, "2.0.0-rc", "2.0.0-rc.beta", true ], - [ :semVerGreaterThan, "2.0.1", "2.0.0", true ], - [ :semVerGreaterThan, "2.0.1", "2.0", true ], - [ :semVerGreaterThan, "2.0.0", "2.0.1", false ], - [ :semVerGreaterThan, "2.0", "2.0.1", false ], - [ :semVerGreaterThan, "2.0.0-rc.1", "2.0.0-rc.0", true ], - [ :semVerLessThan, "2.0.1", "xbad%ver", false ], - [ :semVerGreaterThan, "2.0.1", "xbad%ver", false ] - ] - - operatorTests.each do |params| - op = params[0] - value1 = params[1] - value2 = params[2] - shouldBe = params[3] - it "should return #{shouldBe} for #{value1} #{op} #{value2}" do - user = { key: 'x', custom: { foo: value1 } } - clause = { attribute: 'foo', op: op, values: [value2] } - flag = boolean_flag_with_clauses([clause]) - expect(evaluate(flag, user, features, logger, factory).detail.value).to be shouldBe - end - end - end - - describe "variation_index_for_user" do - it "matches bucket" do - user = { key: "userkey" } - flag_key = "flagkey" - salt = "salt" - - # First verify that with our test inputs, the bucket value will be greater than zero and less than 100000, - # so we can construct a rollout whose second bucket just barely contains that value - bucket_value = (bucket_user(user, flag_key, "key", salt) * 100000).truncate() - expect(bucket_value).to be > 0 - expect(bucket_value).to be < 100000 - - bad_variation_a = 0 - matched_variation = 1 - bad_variation_b = 2 - rule = { - rollout: { - variations: [ - { variation: bad_variation_a, weight: bucket_value }, # end of bucket range is not inclusive, so it will *not* match the target value - { variation: matched_variation, weight: 1 }, # size of this bucket is 1, so it only matches that specific value - { variation: bad_variation_b, weight: 100000 - (bucket_value + 1) } - ] - } - } - flag = { key: flag_key, salt: salt } - - result_variation = variation_index_for_user(flag, rule, user) - expect(result_variation).to be matched_variation - end - - it "uses last bucket if bucket value is equal to total weight" do - user = { key: "userkey" } - flag_key = "flagkey" - salt = "salt" - - bucket_value = (bucket_user(user, flag_key, "key", salt) * 100000).truncate() - - # We'll construct a list of variations that stops right at the target bucket value - rule = { - rollout: { - variations: [ - { variation: 0, weight: bucket_value } - ] - } - } - flag = { key: flag_key, salt: salt } - - result_variation = variation_index_for_user(flag, rule, user) - expect(result_variation).to be 0 - end - end - - describe "bucket_user" do - it "gets expected bucket values for specific keys" do - user = { key: "userKeyA" } - bucket = bucket_user(user, "hashKey", "key", "saltyA") - expect(bucket).to be_within(0.0000001).of(0.42157587); - - user = { key: "userKeyB" } - bucket = bucket_user(user, "hashKey", "key", "saltyA") - expect(bucket).to be_within(0.0000001).of(0.6708485); - - user = { key: "userKeyC" } - bucket = bucket_user(user, "hashKey", "key", "saltyA") - expect(bucket).to be_within(0.0000001).of(0.10343106); - end - - it "can bucket by int value (equivalent to string)" do - user = { - key: "userkey", - custom: { - stringAttr: "33333", - intAttr: 33333 - } - } - stringResult = bucket_user(user, "hashKey", "stringAttr", "saltyA") - intResult = bucket_user(user, "hashKey", "intAttr", "saltyA") - - expect(intResult).to be_within(0.0000001).of(0.54771423) - expect(intResult).to eq(stringResult) - end - - it "cannot bucket by float value" do - user = { - key: "userkey", - custom: { - floatAttr: 33.5 - } - } - result = bucket_user(user, "hashKey", "floatAttr", "saltyA") - expect(result).to eq(0.0) - end - - - it "cannot bucket by bool value" do - user = { - key: "userkey", - custom: { - boolAttr: true - } - } - result = bucket_user(user, "hashKey", "boolAttr", "saltyA") - expect(result).to eq(0.0) - end - end - - def make_segment(key) - { - key: key, - included: [], - excluded: [], - salt: 'abcdef', - version: 1 - } - end - - def make_segment_match_clause(segment) - { - op: :segmentMatch, - values: [ segment[:key] ], - negate: false - } - end - - def make_user_matching_clause(user, attr) - { - attribute: attr.to_s, - op: :in, - values: [ user[attr.to_sym] ], - negate: false - } - end - - describe 'segment matching' do - def test_segment_match(segment) - features.upsert(LaunchDarkly::SEGMENTS, segment) - clause = make_segment_match_clause(segment) - flag = boolean_flag_with_clauses([clause]) - evaluate(flag, user, features, logger, factory).detail.value - 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 excludes user' do - segment = make_segment('segkey') - segment[:excluded] = [ user[:key] ] - expect(test_segment_match(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 '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 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 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 "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 "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 "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 - end - end -end diff --git a/spec/event_sender_spec.rb b/spec/event_sender_spec.rb index 0519aebb..5ad3f2f1 100644 --- a/spec/event_sender_spec.rb +++ b/spec/event_sender_spec.rb @@ -39,12 +39,29 @@ def with_sender_and_server "authorization" => [ sdk_key ], "content-type" => [ "application/json" ], "user-agent" => [ "RubyClient/" + LaunchDarkly::VERSION ], - "x-launchdarkly-event-schema" => [ "3" ] + "x-launchdarkly-event-schema" => [ "3" ], + "connection" => [ "Keep-Alive" ] }) expect(req.header['x-launchdarkly-payload-id']).not_to eq [] end end - + + it "can use a socket factory" do + with_server do |server| + server.setup_ok_response("/bulk", "") + + config = Config.new(events_uri: "http://events.com/bulk", socket_factory: SocketFactoryFromHash.new({"events.com" => server.port}), logger: $null_log) + es = subject.new(sdk_key, config, nil, 0.1) + + result = es.send_event_data(fake_data, "", false) + + expect(result.success).to be true + req = server.await_request + expect(req.body).to eq fake_data + expect(req.host).to eq "events.com" + end + end + it "generates a new payload ID for each payload" do with_sender_and_server do |es, server| server.setup_ok_response("/bulk", "") @@ -78,6 +95,7 @@ def with_sender_and_server "authorization" => [ sdk_key ], "content-type" => [ "application/json" ], "user-agent" => [ "RubyClient/" + LaunchDarkly::VERSION ], + "connection" => [ "Keep-Alive" ] }) expect(req.header['x-launchdarkly-event-schema']).to eq [] expect(req.header['x-launchdarkly-payload-id']).to eq [] diff --git a/spec/http_util.rb b/spec/http_util.rb index 27032589..1a789772 100644 --- a/spec/http_util.rb +++ b/spec/http_util.rb @@ -3,7 +3,7 @@ require "webrick/https" class StubHTTPServer - attr_reader :requests + attr_reader :requests, :port @@next_port = 50000 @@ -120,3 +120,13 @@ def with_server(server = nil) server.stop end end + +class SocketFactoryFromHash + def initialize(ports = {}) + @ports = ports + end + + def open(uri, timeout) + TCPSocket.new 'localhost', @ports[uri] + end +end \ No newline at end of file diff --git a/spec/impl/evaluator_bucketing_spec.rb b/spec/impl/evaluator_bucketing_spec.rb new file mode 100644 index 00000000..a9c79b5c --- /dev/null +++ b/spec/impl/evaluator_bucketing_spec.rb @@ -0,0 +1,111 @@ +require "spec_helper" + +describe LaunchDarkly::Impl::EvaluatorBucketing do + subject { LaunchDarkly::Impl::EvaluatorBucketing } + + describe "bucket_user" do + it "gets expected bucket values for specific keys" do + user = { key: "userKeyA" } + bucket = subject.bucket_user(user, "hashKey", "key", "saltyA") + expect(bucket).to be_within(0.0000001).of(0.42157587); + + user = { key: "userKeyB" } + bucket = subject.bucket_user(user, "hashKey", "key", "saltyA") + expect(bucket).to be_within(0.0000001).of(0.6708485); + + user = { key: "userKeyC" } + bucket = subject.bucket_user(user, "hashKey", "key", "saltyA") + expect(bucket).to be_within(0.0000001).of(0.10343106); + end + + it "can bucket by int value (equivalent to string)" do + user = { + key: "userkey", + custom: { + stringAttr: "33333", + intAttr: 33333 + } + } + stringResult = subject.bucket_user(user, "hashKey", "stringAttr", "saltyA") + intResult = subject.bucket_user(user, "hashKey", "intAttr", "saltyA") + + expect(intResult).to be_within(0.0000001).of(0.54771423) + expect(intResult).to eq(stringResult) + end + + it "cannot bucket by float value" do + user = { + key: "userkey", + custom: { + floatAttr: 33.5 + } + } + result = subject.bucket_user(user, "hashKey", "floatAttr", "saltyA") + expect(result).to eq(0.0) + end + + + it "cannot bucket by bool value" do + user = { + key: "userkey", + custom: { + boolAttr: true + } + } + result = subject.bucket_user(user, "hashKey", "boolAttr", "saltyA") + expect(result).to eq(0.0) + end + end + + describe "variation_index_for_user" do + it "matches bucket" do + user = { key: "userkey" } + flag_key = "flagkey" + salt = "salt" + + # First verify that with our test inputs, the bucket value will be greater than zero and less than 100000, + # so we can construct a rollout whose second bucket just barely contains that value + bucket_value = (subject.bucket_user(user, flag_key, "key", salt) * 100000).truncate() + expect(bucket_value).to be > 0 + expect(bucket_value).to be < 100000 + + bad_variation_a = 0 + matched_variation = 1 + bad_variation_b = 2 + rule = { + rollout: { + variations: [ + { variation: bad_variation_a, weight: bucket_value }, # end of bucket range is not inclusive, so it will *not* match the target value + { variation: matched_variation, weight: 1 }, # size of this bucket is 1, so it only matches that specific value + { variation: bad_variation_b, weight: 100000 - (bucket_value + 1) } + ] + } + } + flag = { key: flag_key, salt: salt } + + result_variation = subject.variation_index_for_user(flag, rule, user) + expect(result_variation).to be matched_variation + end + + it "uses last bucket if bucket value is equal to total weight" do + user = { key: "userkey" } + flag_key = "flagkey" + salt = "salt" + + bucket_value = (subject.bucket_user(user, flag_key, "key", salt) * 100000).truncate() + + # We'll construct a list of variations that stops right at the target bucket value + rule = { + rollout: { + variations: [ + { variation: 0, weight: bucket_value } + ] + } + } + flag = { key: flag_key, salt: salt } + + result_variation = subject.variation_index_for_user(flag, rule, user) + expect(result_variation).to be 0 + end + end +end diff --git a/spec/impl/evaluator_clause_spec.rb b/spec/impl/evaluator_clause_spec.rb new file mode 100644 index 00000000..a90a5499 --- /dev/null +++ b/spec/impl/evaluator_clause_spec.rb @@ -0,0 +1,55 @@ +require "spec_helper" +require "impl/evaluator_spec_base" + +module LaunchDarkly + module Impl + describe "Evaluator (clauses)", :evaluator_spec_base => true do + subject { Evaluator } + + 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, factory).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, factory).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, factory).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, factory).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, factory).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, factory).detail.value).to be false + end + end + end +end diff --git a/spec/impl/evaluator_operators_spec.rb b/spec/impl/evaluator_operators_spec.rb new file mode 100644 index 00000000..ddf55cc7 --- /dev/null +++ b/spec/impl/evaluator_operators_spec.rb @@ -0,0 +1,141 @@ +require "spec_helper" + +describe LaunchDarkly::Impl::EvaluatorOperators do + subject { LaunchDarkly::Impl::EvaluatorOperators } + + describe "operators" do + dateStr1 = "2017-12-06T00:00:00.000-07:00" + dateStr2 = "2017-12-06T00:01:01.000-07:00" + dateMs1 = 10000000 + dateMs2 = 10000001 + invalidDate = "hey what's this?" + + operatorTests = [ + # numeric comparisons + [ :in, 99, 99, true ], + [ :in, 99.0001, 99.0001, true ], + [ :in, 99, 99.0001, false ], + [ :in, 99.0001, 99, false ], + [ :lessThan, 99, 99.0001, true ], + [ :lessThan, 99.0001, 99, false ], + [ :lessThan, 99, 99, false ], + [ :lessThanOrEqual, 99, 99.0001, true ], + [ :lessThanOrEqual, 99.0001, 99, false ], + [ :lessThanOrEqual, 99, 99, true ], + [ :greaterThan, 99.0001, 99, true ], + [ :greaterThan, 99, 99.0001, false ], + [ :greaterThan, 99, 99, false ], + [ :greaterThanOrEqual, 99.0001, 99, true ], + [ :greaterThanOrEqual, 99, 99.0001, false ], + [ :greaterThanOrEqual, 99, 99, true ], + + # string comparisons + [ :in, "x", "x", true ], + [ :in, "x", "xyz", false ], + [ :startsWith, "xyz", "x", true ], + [ :startsWith, "x", "xyz", false ], + [ :endsWith, "xyz", "z", true ], + [ :endsWith, "z", "xyz", false ], + [ :contains, "xyz", "y", true ], + [ :contains, "y", "xyz", false ], + + # mixed strings and numbers + [ :in, "99", 99, false ], + [ :in, 99, "99", false ], + [ :contains, "99", 99, false ], + [ :startsWith, "99", 99, false ], + [ :endsWith, "99", 99, false ], + [ :lessThanOrEqual, "99", 99, false ], + [ :lessThanOrEqual, 99, "99", false ], + [ :greaterThanOrEqual, "99", 99, false ], + [ :greaterThanOrEqual, 99, "99", false ], + + # regex + [ :matches, "hello world", "hello.*rld", true ], + [ :matches, "hello world", "hello.*orl", true ], + [ :matches, "hello world", "l+", true ], + [ :matches, "hello world", "(world|planet)", true ], + [ :matches, "hello world", "aloha", false ], + [ :matches, "hello world", "***not a regex", false ], + + # dates + [ :before, dateStr1, dateStr2, true ], + [ :before, dateMs1, dateMs2, true ], + [ :before, dateStr2, dateStr1, false ], + [ :before, dateMs2, dateMs1, false ], + [ :before, dateStr1, dateStr1, false ], + [ :before, dateMs1, dateMs1, false ], + [ :before, dateStr1, invalidDate, false ], + [ :after, dateStr1, dateStr2, false ], + [ :after, dateMs1, dateMs2, false ], + [ :after, dateStr2, dateStr1, true ], + [ :after, dateMs2, dateMs1, true ], + [ :after, dateStr1, dateStr1, false ], + [ :after, dateMs1, dateMs1, false ], + [ :after, dateStr1, invalidDate, false ], + + # semver + [ :semVerEqual, "2.0.1", "2.0.1", true ], + [ :semVerEqual, "2.0", "2.0.0", true ], + [ :semVerEqual, "2-rc1", "2.0.0-rc1", true ], + [ :semVerEqual, "2+build2", "2.0.0+build2", true ], + [ :semVerLessThan, "2.0.0", "2.0.1", true ], + [ :semVerLessThan, "2.0", "2.0.1", true ], + [ :semVerLessThan, "2.0.1", "2.0.0", false ], + [ :semVerLessThan, "2.0.1", "2.0", false ], + [ :semVerLessThan, "2.0.0-rc", "2.0.0-rc.beta", true ], + [ :semVerGreaterThan, "2.0.1", "2.0.0", true ], + [ :semVerGreaterThan, "2.0.1", "2.0", true ], + [ :semVerGreaterThan, "2.0.0", "2.0.1", false ], + [ :semVerGreaterThan, "2.0", "2.0.1", false ], + [ :semVerGreaterThan, "2.0.0-rc.1", "2.0.0-rc.0", true ], + [ :semVerLessThan, "2.0.1", "xbad%ver", false ], + [ :semVerGreaterThan, "2.0.1", "xbad%ver", false ] + ] + + operatorTests.each do |params| + op = params[0] + value1 = params[1] + value2 = params[2] + shouldBe = params[3] + it "should return #{shouldBe} for #{value1} #{op} #{value2}" do + expect(subject::apply(op, value1, value2)).to be shouldBe + end + end + end + + describe "user_value" do + [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous, :some_custom_attr].each do |attr| + it "returns nil if property #{attr} is not defined" do + expect(subject::user_value({}, attr)).to be nil + end + end + + [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name].each do |attr| + it "gets string value of string property #{attr}" do + expect(subject::user_value({ attr => 'x' }, attr)).to eq 'x' + end + + it "coerces non-string value of property #{attr} to string" do + expect(subject::user_value({ attr => 3 }, attr)).to eq '3' + end + end + + it "gets boolean value of property anonymous" do + expect(subject::user_value({ anonymous: true }, :anonymous)).to be true + expect(subject::user_value({ anonymous: false }, :anonymous)).to be false + end + + it "does not coerces non-boolean value of property anonymous" do + expect(subject::user_value({ anonymous: 3 }, :anonymous)).to eq 3 + end + + it "gets string value of custom property" do + expect(subject::user_value({ custom: { some_custom_attr: 'x' } }, :some_custom_attr)).to eq 'x' + end + + it "gets non-string value of custom property" do + expect(subject::user_value({ custom: { some_custom_attr: 3 } }, :some_custom_attr)).to eq 3 + end + end +end diff --git a/spec/impl/evaluator_rule_spec.rb b/spec/impl/evaluator_rule_spec.rb new file mode 100644 index 00000000..a1ae5d66 --- /dev/null +++ b/spec/impl/evaluator_rule_spec.rb @@ -0,0 +1,96 @@ +require "spec_helper" +require "impl/evaluator_spec_base" + +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, factory) + expect(result.detail).to eq(detail) + expect(result.events).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, factory) + result2 = basic_evaluator.evaluate(flag, user, factory) + 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, factory) + expect(result.detail).to eq(detail) + expect(result.events).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, factory) + expect(result.detail).to eq(detail) + expect(result.events).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, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(nil) + 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, factory) + expect(result.detail).to eq(detail) + expect(result.events).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, factory) + expect(result.detail.value).to eq(true) + 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, factory) + expect(result.detail.reason).to eq(EvaluationReason::rule_match(0, 'ruleid')) + end + end + end +end diff --git a/spec/impl/evaluator_segment_spec.rb b/spec/impl/evaluator_segment_spec.rb new file mode 100644 index 00000000..64fb1bc7 --- /dev/null +++ b/spec/impl/evaluator_segment_spec.rb @@ -0,0 +1,125 @@ +require "spec_helper" +require "impl/evaluator_spec_base" + +module LaunchDarkly + module Impl + describe "Evaluator (segments)", :evaluator_spec_base => true do + subject { Evaluator } + + def test_segment_match(segment) + clause = make_segment_match_clause(segment) + flag = boolean_flag_with_clauses([clause]) + e = Evaluator.new(get_nothing, get_things({ segment[:key] => segment }), logger) + e.evaluate(flag, user, factory).detail.value + end + + it "retrieves segment from segment store for segmentMatch operator" do + segment = { + key: 'segkey', + included: [ 'userkey' ], + version: 1, + deleted: false + } + get_segment = get_things({ 'segkey' => segment }) + e = subject.new(get_nothing, get_segment, logger) + user = { key: 'userkey' } + clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] } + flag = boolean_flag_with_clauses([clause]) + expect(e.evaluate(flag, user, factory).detail.value).to be true + end + + it "falls through with no errors if referenced segment is not found" do + e = subject.new(get_nothing, get_things({ 'segkey' => nil }), logger) + user = { key: 'userkey' } + clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] } + flag = boolean_flag_with_clauses([clause]) + expect(e.evaluate(flag, user, factory).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 excludes user' do + segment = make_segment('segkey') + segment[:excluded] = [ user[:key] ] + expect(test_segment_match(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 '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 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 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 "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 "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 "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 + end + end + end +end diff --git a/spec/impl/evaluator_spec.rb b/spec/impl/evaluator_spec.rb new file mode 100644 index 00000000..dcf8928b --- /dev/null +++ b/spec/impl/evaluator_spec.rb @@ -0,0 +1,305 @@ +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 + it "returns off variation if flag is off" do + 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, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(nil) + end + + it "returns nil if flag is off and off variation is unspecified" do + 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, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(nil) + end + + it "returns an error if off variation is too high" do + 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)) + result = basic_evaluator.evaluate(flag, user, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(nil) + end + + it "returns an error if off variation is negative" do + 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)) + result = basic_evaluator.evaluate(flag, user, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(nil) + end + + it "returns off variation if prerequisite is not found" do + 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 = subject.new(get_things( 'badfeature' => nil ), get_nothing, logger) + result = e.evaluate(flag, user, factory) + expect(result.detail).to eq(detail) + expect(result.events).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 = subject.new(get_things( 'badfeature' => nil ), get_nothing, logger) + result1 = e.evaluate(flag, user, factory) + expect(result1.detail.reason).to eq EvaluationReason::prerequisite_failed('badfeature') + result2 = e.evaluate(flag, user, factory) + expect(result2.detail.reason).to be result1.detail.reason + end + + it "returns off variation and event if prerequisite of a prerequisite is not found" do + flag = { + key: 'feature0', + on: true, + prerequisites: [{key: 'feature1', variation: 1}], + fallthrough: { variation: 0 }, + offVariation: 1, + variations: ['a', 'b', 'c'], + version: 1 + } + flag1 = { + 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')) + events_should_be = [{ + kind: 'feature', key: 'feature1', user: user, value: nil, default: nil, variation: nil, version: 2, prereqOf: 'feature0' + }] + get_flag = get_things('feature1' => flag1, 'feature2' => nil) + e = subject.new(get_flag, get_nothing, logger) + result = e.evaluate(flag, user, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(events_should_be) + end + + it "returns off variation and event if prerequisite is off" do + flag = { + key: 'feature0', + on: true, + prerequisites: [{key: 'feature1', variation: 1}], + fallthrough: { variation: 0 }, + offVariation: 1, + variations: ['a', 'b', 'c'], + version: 1 + } + flag1 = { + key: 'feature1', + on: false, + # note that even though it returns the desired variation, it is still off and therefore not a match + offVariation: 1, + fallthrough: { variation: 0 }, + variations: ['d', 'e'], + version: 2 + } + user = { key: 'x' } + detail = EvaluationDetail.new('b', 1, EvaluationReason::prerequisite_failed('feature1')) + events_should_be = [{ + kind: 'feature', key: 'feature1', user: user, variation: 1, value: 'e', default: nil, version: 2, prereqOf: 'feature0' + }] + get_flag = get_things({ 'feature1' => flag1 }) + e = subject.new(get_flag, get_nothing, logger) + result = e.evaluate(flag, user, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(events_should_be) + end + + it "returns off variation and event if prerequisite is not met" do + flag = { + key: 'feature0', + on: true, + prerequisites: [{key: 'feature1', variation: 1}], + fallthrough: { variation: 0 }, + offVariation: 1, + variations: ['a', 'b', 'c'], + version: 1 + } + flag1 = { + key: 'feature1', + on: true, + fallthrough: { variation: 0 }, + variations: ['d', 'e'], + version: 2 + } + user = { key: 'x' } + detail = EvaluationDetail.new('b', 1, EvaluationReason::prerequisite_failed('feature1')) + events_should_be = [{ + kind: 'feature', key: 'feature1', user: user, variation: 0, value: 'd', default: nil, version: 2, prereqOf: 'feature0' + }] + get_flag = get_things({ 'feature1' => flag1 }) + e = subject.new(get_flag, get_nothing, logger) + result = e.evaluate(flag, user, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(events_should_be) + end + + it "returns fallthrough variation and event if prerequisite is met and there are no rules" do + flag = { + key: 'feature0', + on: true, + prerequisites: [{key: 'feature1', variation: 1}], + fallthrough: { variation: 0 }, + offVariation: 1, + variations: ['a', 'b', 'c'], + version: 1 + } + flag1 = { + key: 'feature1', + on: true, + fallthrough: { variation: 1 }, + variations: ['d', 'e'], + version: 2 + } + user = { key: 'x' } + detail = EvaluationDetail.new('a', 0, EvaluationReason::fallthrough) + events_should_be = [{ + kind: 'feature', key: 'feature1', user: user, variation: 1, value: 'e', default: nil, version: 2, prereqOf: 'feature0' + }] + get_flag = get_things({ 'feature1' => flag1 }) + e = subject.new(get_flag, get_nothing, logger) + result = e.evaluate(flag, user, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(events_should_be) + end + + it "returns an error if fallthrough variation is too high" do + 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, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(nil) + end + + it "returns an error if fallthrough variation is negative" do + 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, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(nil) + end + + it "returns an error if fallthrough has no variation or rollout" do + 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, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(nil) + end + + it "returns an error if fallthrough has a rollout with no variations" do + 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, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(nil) + end + + it "matches user from targets" do + 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) + result = basic_evaluator.evaluate(flag, user, factory) + expect(result.detail).to eq(detail) + expect(result.events).to eq(nil) + end + end + end + end +end diff --git a/spec/impl/evaluator_spec_base.rb b/spec/impl/evaluator_spec_base.rb new file mode 100644 index 00000000..fa8b86c3 --- /dev/null +++ b/spec/impl/evaluator_spec_base.rb @@ -0,0 +1,75 @@ +require "spec_helper" + +module LaunchDarkly + module Impl + module EvaluatorSpecBase + def factory + EventFactory.new(false) + end + + def user + { + key: "userkey", + email: "test@example.com", + name: "Bob" + } + end + + def logger + ::Logger.new($stdout, level: ::Logger::FATAL) + end + + def get_nothing + lambda { |key| raise "should not have requested #{key}" } + end + + def get_things(map) + lambda { |key| + raise "should not have requested #{key}" if !map.has_key?(key) + map[key] + } + end + + def basic_evaluator + subject.new(get_nothing, get_nothing, logger) + 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) + { + attribute: attr.to_s, + op: :in, + values: [ user[attr.to_sym] ], + negate: false + } + end + + def make_segment(key) + { + key: key, + included: [], + excluded: [], + salt: 'abcdef', + version: 1 + } + end + + def make_segment_match_clause(segment) + { + op: :segmentMatch, + values: [ segment[:key] ], + negate: false + } + end + end + + RSpec.configure { |c| c.include EvaluatorSpecBase, :evaluator_spec_base => true } + end +end diff --git a/spec/impl/model/serialization_spec.rb b/spec/impl/model/serialization_spec.rb new file mode 100644 index 00000000..0a26bcd5 --- /dev/null +++ b/spec/impl/model/serialization_spec.rb @@ -0,0 +1,41 @@ +require "spec_helper" + +module LaunchDarkly + module Impl + module Model + describe "model serialization" do + it "serializes flag" do + flag = { key: "flagkey", version: 1 } + json = Model.serialize(FEATURES, flag) + expect(JSON.parse(json, symbolize_names: true)).to eq flag + end + + it "serializes segment" do + segment = { key: "segkey", version: 1 } + json = Model.serialize(SEGMENTS, segment) + expect(JSON.parse(json, symbolize_names: true)).to eq segment + end + + it "serializes arbitrary data kind" do + thing = { key: "thingkey", name: "me" } + json = Model.serialize({ name: "things" }, thing) + expect(JSON.parse(json, symbolize_names: true)).to eq thing + end + + it "deserializes flag with no rules or prerequisites" do + flag_in = { key: "flagkey", version: 1 } + json = Model.serialize(FEATURES, flag_in) + flag_out = Model.deserialize(FEATURES, json) + expect(flag_out).to eq flag_in + end + + it "deserializes segment" do + segment_in = { key: "segkey", version: 1 } + json = Model.serialize(SEGMENTS, segment_in) + segment_out = Model.deserialize(SEGMENTS, json) + expect(segment_out).to eq segment_in + end + end + end + end +end diff --git a/spec/launchdarkly-server-sdk_spec.rb b/spec/launchdarkly-server-sdk_spec.rb index b594dac8..6dfa4808 100644 --- a/spec/launchdarkly-server-sdk_spec.rb +++ b/spec/launchdarkly-server-sdk_spec.rb @@ -4,7 +4,7 @@ describe LaunchDarkly do it "can be automatically loaded by Bundler.require" do ldclient_loaded = - Bundler.with_clean_env do + Bundler.with_unbundled_env do Kernel.system("ruby", "./spec/launchdarkly-server-sdk_spec_autoloadtest.rb") end diff --git a/spec/ldclient_end_to_end_spec.rb b/spec/ldclient_end_to_end_spec.rb index b93a98b4..a820b608 100644 --- a/spec/ldclient_end_to_end_spec.rb +++ b/spec/ldclient_end_to_end_spec.rb @@ -80,6 +80,7 @@ module LaunchDarkly req, body = events_server.await_request_with_body expect(req.header['authorization']).to eq [ SDK_KEY ] + expect(req.header['connection']).to eq [ "Keep-Alive" ] data = JSON.parse(body) expect(data.length).to eq 1 expect(data[0]["kind"]).to eq "identify" @@ -111,6 +112,7 @@ module LaunchDarkly req = req0.path == "/diagnostic" ? req0 : req1 body = req0.path == "/diagnostic" ? body0 : body1 expect(req.header['authorization']).to eq [ SDK_KEY ] + expect(req.header['connection']).to eq [ "Keep-Alive" ] data = JSON.parse(body) expect(data["kind"]).to eq "diagnostic-init" end @@ -118,6 +120,38 @@ module LaunchDarkly end end + it "can use socket factory" do + with_server do |poll_server| + with_server do |events_server| + events_server.setup_ok_response("/bulk", "") + poll_server.setup_ok_response("/sdk/latest-all", '{"flags":{},"segments":{}}', "application/json") + + config = Config.new( + stream: false, + base_uri: "http://polling.com", + events_uri: "http://events.com", + diagnostic_opt_out: true, + logger: NullLogger.new, + socket_factory: SocketFactoryFromHash.new({ + "polling.com" => poll_server.port, + "events.com" => events_server.port + }) + ) + with_client(config) do |client| + client.identify(USER) + client.flush + + req, body = events_server.await_request_with_body + expect(req.header['authorization']).to eq [ SDK_KEY ] + expect(req.header['connection']).to eq [ "Keep-Alive" ] + data = JSON.parse(body) + expect(data.length).to eq 1 + expect(data[0]["kind"]).to eq "identify" + end + end + end + end + # TODO: TLS tests with self-signed cert end end diff --git a/spec/ldclient_spec.rb b/spec/ldclient_spec.rb index 40ce5a1d..76e5b0f7 100644 --- a/spec/ldclient_spec.rb +++ b/spec/ldclient_spec.rb @@ -197,7 +197,7 @@ def event_processor value: 'value', default: 'default', trackEvents: true, - reason: { kind: 'RULE_MATCH', ruleIndex: 0, ruleId: 'id' } + reason: LaunchDarkly::EvaluationReason::rule_match(0, 'id') )) client.variation('flag', user, 'default') end @@ -222,7 +222,7 @@ def event_processor value: 'value', default: 'default', trackEvents: true, - reason: { kind: 'FALLTHROUGH' } + reason: LaunchDarkly::EvaluationReason::fallthrough )) client.variation('flag', user, 'default') end @@ -234,20 +234,22 @@ def event_processor it "returns the default value if the client is offline" do result = offline_client.variation_detail("doesntmatter", user, "default") - expected = LaunchDarkly::EvaluationDetail.new("default", nil, { kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' }) + expected = LaunchDarkly::EvaluationDetail.new("default", nil, + LaunchDarkly::EvaluationReason::error(LaunchDarkly::EvaluationReason::ERROR_CLIENT_NOT_READY)) expect(result).to eq expected end it "returns the default value for an unknown feature" do result = client.variation_detail("badkey", user, "default") - expected = LaunchDarkly::EvaluationDetail.new("default", nil, { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND'}) + expected = LaunchDarkly::EvaluationDetail.new("default", nil, + LaunchDarkly::EvaluationReason::error(LaunchDarkly::EvaluationReason::ERROR_FLAG_NOT_FOUND)) expect(result).to eq expected end it "queues a feature request event for an unknown feature" do expect(event_processor).to receive(:add_event).with(hash_including( kind: "feature", key: "badkey", user: user, value: "default", default: "default", - reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } + reason: LaunchDarkly::EvaluationReason::error(LaunchDarkly::EvaluationReason::ERROR_FLAG_NOT_FOUND) )) client.variation_detail("badkey", user, "default") end @@ -256,7 +258,7 @@ def event_processor config.feature_store.init({ LaunchDarkly::FEATURES => {} }) config.feature_store.upsert(LaunchDarkly::FEATURES, feature_with_value) result = client.variation_detail("key", user, "default") - expected = LaunchDarkly::EvaluationDetail.new("value", 0, { kind: 'OFF' }) + expected = LaunchDarkly::EvaluationDetail.new("value", 0, LaunchDarkly::EvaluationReason::off) expect(result).to eq expected end @@ -265,7 +267,7 @@ def event_processor config.feature_store.init({ LaunchDarkly::FEATURES => {} }) config.feature_store.upsert(LaunchDarkly::FEATURES, empty_feature) result = client.variation_detail("key", user, "default") - expected = LaunchDarkly::EvaluationDetail.new("default", nil, { kind: 'OFF' }) + expected = LaunchDarkly::EvaluationDetail.new("default", nil, LaunchDarkly::EvaluationReason::off) expect(result).to eq expected expect(result.default_value?).to be true end @@ -283,7 +285,7 @@ def event_processor default: "default", trackEvents: true, debugEventsUntilDate: 1000, - reason: { kind: "OFF" } + reason: LaunchDarkly::EvaluationReason::off )) client.variation_detail("key", user, "default") end diff --git a/spec/polling_spec.rb b/spec/polling_spec.rb index b0eb46c5..ca36364c 100644 --- a/spec/polling_spec.rb +++ b/spec/polling_spec.rb @@ -19,10 +19,10 @@ def with_processor(store) flag = { key: 'flagkey', version: 1 } segment = { key: 'segkey', version: 1 } all_data = { - flags: { + LaunchDarkly::FEATURES => { flagkey: flag }, - segments: { + LaunchDarkly::SEGMENTS => { segkey: segment } } diff --git a/spec/redis_feature_store_spec.rb b/spec/redis_feature_store_spec.rb index e3a179b1..6dd5733e 100644 --- a/spec/redis_feature_store_spec.rb +++ b/spec/redis_feature_store_spec.rb @@ -1,5 +1,5 @@ -require "connection_pool" require "feature_store_spec_base" +require "connection_pool" require "json" require "redis" require "spec_helper" @@ -28,7 +28,7 @@ def clear_all_data describe LaunchDarkly::RedisFeatureStore do subject { LaunchDarkly::RedisFeatureStore } - + break if ENV['LD_SKIP_DATABASE_TESTS'] == '1' # These tests will all fail if there isn't a Redis instance running on the default port. diff --git a/spec/requestor_spec.rb b/spec/requestor_spec.rb index 6751517a..6649f4bb 100644 --- a/spec/requestor_spec.rb +++ b/spec/requestor_spec.rb @@ -35,7 +35,7 @@ def with_requestor(base_uri, opts = {}) with_requestor(server.base_uri.to_s) do |requestor| server.setup_ok_response("/", expected_data.to_json) data = requestor.request_all_data() - expect(data).to eq expected_data + expect(data).to eq LaunchDarkly::Impl::Model.make_all_store_data(expected_data) end end end @@ -91,7 +91,7 @@ def with_requestor(base_uri, opts = {}) data = requestor.request_all_data() expect(server.requests.count).to eq 2 expect(server.requests[1].header).to include({ "if-none-match" => [ etag ] }) - expect(data).to eq expected_data + expect(data).to eq LaunchDarkly::Impl::Model.make_all_store_data(expected_data) end end end @@ -109,14 +109,14 @@ def with_requestor(base_uri, opts = {}) res["ETag"] = etag1 end data = requestor.request_all_data() - expect(data).to eq expected_data1 + expect(data).to eq LaunchDarkly::Impl::Model.make_all_store_data(expected_data1) expect(server.requests.count).to eq 1 server.setup_response("/") do |req, res| res.status = 304 end data = requestor.request_all_data() - expect(data).to eq expected_data1 + expect(data).to eq LaunchDarkly::Impl::Model.make_all_store_data(expected_data1) expect(server.requests.count).to eq 2 expect(server.requests[1].header).to include({ "if-none-match" => [ etag1 ] }) @@ -126,7 +126,7 @@ def with_requestor(base_uri, opts = {}) res["ETag"] = etag2 end data = requestor.request_all_data() - expect(data).to eq expected_data2 + expect(data).to eq LaunchDarkly::Impl::Model.make_all_store_data(expected_data2) expect(server.requests.count).to eq 3 expect(server.requests[2].header).to include({ "if-none-match" => [ etag1 ] }) @@ -134,7 +134,7 @@ def with_requestor(base_uri, opts = {}) res.status = 304 end data = requestor.request_all_data() - expect(data).to eq expected_data2 + expect(data).to eq LaunchDarkly::Impl::Model.make_all_store_data(expected_data2) expect(server.requests.count).to eq 4 expect(server.requests[3].header).to include({ "if-none-match" => [ etag2 ] }) end @@ -147,7 +147,7 @@ def with_requestor(base_uri, opts = {}) server.setup_ok_response("/sdk/latest-all", content, "application/json") with_requestor(server.base_uri.to_s) do |requestor| data = requestor.request_all_data - expect(data).to eq(JSON.parse(content, symbolize_names: true)) + expect(data).to eq(LaunchDarkly::Impl::Model.make_all_store_data(JSON.parse(content, symbolize_names: true))) end end end @@ -159,7 +159,7 @@ def with_requestor(base_uri, opts = {}) "text/plain; charset=ISO-8859-2") with_requestor(server.base_uri.to_s) do |requestor| data = requestor.request_all_data - expect(data).to eq(JSON.parse(content, symbolize_names: true)) + expect(data).to eq(LaunchDarkly::Impl::Model.make_all_store_data(JSON.parse(content, symbolize_names: true))) end end end @@ -176,15 +176,15 @@ def with_requestor(base_uri, opts = {}) end it "can use a proxy server" do - content = '{"flags": {"flagkey": {"key": "flagkey"}}}' + expected_data = { flags: { flagkey: { key: "flagkey" } } } with_server do |server| - server.setup_ok_response("/sdk/latest-all", content, "application/json", { "etag" => "x" }) + server.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json", { "etag" => "x" }) with_server(StubProxyServer.new) do |proxy| begin ENV["http_proxy"] = proxy.base_uri.to_s with_requestor(server.base_uri.to_s) do |requestor| data = requestor.request_all_data - expect(data).to eq(JSON.parse(content, symbolize_names: true)) + expect(data).to eq(LaunchDarkly::Impl::Model.make_all_store_data(expected_data)) end ensure ENV["http_proxy"] = nil