diff --git a/.circleci/config.yml b/.circleci/config.yml index 8ddba394..7ec25b1a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,8 +17,8 @@ workflows: name: Ruby 3.0 docker-image: cimg/ruby:3.0 - build-test-linux: - name: JRuby 9.2 - docker-image: jruby:9.2-jdk + name: JRuby 9.3 + docker-image: jruby:9.3-jdk jruby: true jobs: @@ -41,7 +41,7 @@ jobs: - when: condition: <> steps: - - run: gem install jruby-openssl # required by bundler, no effect on Ruby MRI + - run: gem install jruby-openssl -v 0.11.0 # required by bundler, no effect on Ruby MRI - run: apt-get update -y && apt-get install -y build-essential - when: condition: @@ -49,11 +49,22 @@ jobs: steps: - run: sudo apt-get update -y && sudo apt-get install -y build-essential - run: ruby -v - - run: gem install bundler -v 2.2.10 - - run: bundle _2.2.10_ install - - run: mkdir ./rspec - - run: bundle _2.2.10_ exec rspec --format documentation --format RspecJunitFormatter -o ./rspec/rspec.xml spec + - run: gem install bundler -v 2.2.33 + - run: bundle _2.2.33_ install + - run: mkdir /tmp/circle-artifacts + - run: bundle _2.2.33_ exec rspec --format documentation --format RspecJunitFormatter -o /tmp/circle-artifacts/rspec.xml spec + + - when: + condition: + not: <> + steps: + - run: make build-contract-tests + - run: + command: make start-contract-test-service + background: true + - run: TEST_HARNESS_PARAMS="-junit /tmp/circle-artifacts/contract-tests-junit.xml" make run-contract-tests + - store_test_results: - path: ./rspec + path: /tmp/circle-artifacts - store_artifacts: - path: ./rspec + path: /tmp/circle-artifacts diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..5b264f57 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +TEMP_TEST_OUTPUT=/tmp/contract-test-service.log + +build-contract-tests: + @cd contract-tests && bundle _2.2.33_ install + +start-contract-test-service: + @cd contract-tests && bundle _2.2.33_ exec ruby service.rb + +start-contract-test-service-bg: + @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" + @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & + +run-contract-tests: + @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v1.0.0/downloader/run.sh \ + | VERSION=v1 PARAMS="-url http://localhost:9000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh + +contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests + +.PHONY: build-contract-tests start-contract-test-service run-contract-tests contract-tests diff --git a/README.md b/README.md index 8125c068..17e3bfc5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ LaunchDarkly Server-side SDK for Ruby [![Gem Version](https://badge.fury.io/rb/launchdarkly-server-sdk.svg)](http://badge.fury.io/rb/launchdarkly-server-sdk) [![Circle CI](https://circleci.com/gh/launchdarkly/ruby-server-sdk/tree/master.svg?style=svg)](https://circleci.com/gh/launchdarkly/ruby-server-sdk/tree/master) -[![Security](https://hakiri.io/github/launchdarkly/ruby-server-sdk/master.svg)](https://hakiri.io/github/launchdarkly/ruby-server-sdk/master) [![RubyDoc](https://img.shields.io/static/v1?label=docs+-+all+versions&message=reference&color=00add8)](https://www.rubydoc.info/gems/launchdarkly-server-sdk) [![GitHub Pages](https://img.shields.io/static/v1?label=docs+-+latest&message=reference&color=00add8)](https://launchdarkly.github.io/ruby-server-sdk) diff --git a/contract-tests/Gemfile b/contract-tests/Gemfile new file mode 100644 index 00000000..48b8812f --- /dev/null +++ b/contract-tests/Gemfile @@ -0,0 +1,10 @@ +source 'https://rubygems.org' + +gem 'launchdarkly-server-sdk', path: '..' + +gem 'sinatra', '~> 2.1' +# Sinatra can work with several server frameworks. In JRuby, we have to use glassfish (which +# is only available in JRuby). Otherwise we use thin (which is not available in JRuby). +gem 'glassfish', :platforms => :jruby +gem 'thin', :platforms => :ruby +gem 'json' diff --git a/contract-tests/README.md b/contract-tests/README.md new file mode 100644 index 00000000..aa3942b8 --- /dev/null +++ b/contract-tests/README.md @@ -0,0 +1,7 @@ +# SDK contract test service + +This directory contains an implementation of the cross-platform SDK testing protocol defined by https://github.com/launchdarkly/sdk-test-harness. See that project's `README` for details of this protocol, and the kinds of SDK capabilities that are relevant to the contract tests. This code should not need to be updated unless the SDK has added or removed such capabilities. + +To run these tests locally, run `make contract-tests` from the SDK project root directory. This downloads the correct version of the test harness tool automatically. + +Or, to test against an in-progress local version of the test harness, run `make start-contract-test-service` from the SDK project root directory; then, in the root directory of the `sdk-test-harness` project, build the test harness and run it from the command line. diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb new file mode 100644 index 00000000..a9b7ccd5 --- /dev/null +++ b/contract-tests/client_entity.rb @@ -0,0 +1,92 @@ +require 'ld-eventsource' +require 'json' +require 'net/http' + +class ClientEntity + def initialize(log, config) + @log = log + + opts = {} + + opts[:logger] = log + + if config[:streaming] + streaming = config[:streaming] + opts[:stream_uri] = streaming[:baseUri] if !streaming[:baseUri].nil? + opts[:initial_reconnect_delay] = streaming[:initialRetryDelayMs] / 1_000.0 if !streaming[:initialRetryDelayMs].nil? + end + + if config[:events] + events = config[:events] + opts[:events_uri] = events[:baseUri] if events[:baseUri] + opts[:capacity] = events[:capacity] if events[:capacity] + opts[:diagnostic_opt_out] = !events[:enableDiagnostics] + opts[:all_attributes_private] = !!events[:allAttributesPrivate] + opts[:private_attribute_names] = events[:globalPrivateAttributes] + opts[:flush_interval] = (events[:flushIntervalMs] / 1_000) if events.has_key? :flushIntervalMs + opts[:inline_users_in_events] = events[:inlineUsers] || false + else + opts[:send_events] = false + end + + startWaitTimeMs = config[:startWaitTimeMs] || 5_000 + + @client = LaunchDarkly::LDClient.new( + config[:credential], + LaunchDarkly::Config.new(opts), + startWaitTimeMs / 1_000.0) + end + + def initialized? + @client.initialized? + end + + def evaluate(params) + response = {} + + if params[:detail] + detail = @client.variation_detail(params[:flagKey], params[:user], params[:defaultValue]) + response[:value] = detail.value + response[:variationIndex] = detail.variation_index + response[:reason] = detail.reason + else + response[:value] = @client.variation(params[:flagKey], params[:user], params[:defaultValue]) + end + + response + end + + def evaluate_all(params) + opts = {} + opts[:client_side_only] = params[:clientSideOnly] || false + opts[:with_reasons] = params[:withReasons] || false + opts[:details_only_for_tracked_flags] = params[:detailsOnlyForTrackedFlags] || false + + @client.all_flags_state(params[:user], opts) + end + + def track(params) + @client.track(params[:eventKey], params[:user], params[:data], params[:metricValue]) + end + + def identify(params) + @client.identify(params[:user]) + end + + def alias(params) + @client.alias(params[:user], params[:previousUser]) + end + + def flush_events + @client.flush + end + + def log + @log + end + + def close + @client.close + @log.info("Test ended") + end +end diff --git a/contract-tests/service.rb b/contract-tests/service.rb new file mode 100644 index 00000000..54cc0b73 --- /dev/null +++ b/contract-tests/service.rb @@ -0,0 +1,112 @@ +require 'launchdarkly-server-sdk' +require 'json' +require 'logger' +require 'net/http' +require 'sinatra' + +require './client_entity.rb' + +configure :development do + disable :show_exceptions +end + +$log = Logger.new(STDOUT) +$log.formatter = proc {|severity, datetime, progname, msg| + "[GLOBAL] #{datetime.strftime('%Y-%m-%d %H:%M:%S.%3N')} #{severity} #{progname} #{msg}\n" +} + +set :port, 9000 +set :logging, false + +clients = {} +clientCounter = 0 + +get '/' do + { + capabilities: [ + 'server-side', + 'all-flags-with-reasons', + 'all-flags-client-side-only', + 'all-flags-details-only-for-tracked-flags', + ] + }.to_json +end + +delete '/' do + $log.info("Test service has told us to exit") + Thread.new { sleep 1; exit } + return 204 +end + +post '/' do + opts = JSON.parse(request.body.read, :symbolize_names => true) + tag = "[#{opts[:tag]}]" + + clientCounter += 1 + clientId = clientCounter.to_s + + log = Logger.new(STDOUT) + log.formatter = proc {|severity, datetime, progname, msg| + "#{tag} #{datetime.strftime('%Y-%m-%d %H:%M:%S.%3N')} #{severity} #{progname} #{msg}\n" + } + + log.info("Starting client") + log.debug("Parameters: #{opts}") + + client = ClientEntity.new(log, opts[:configuration]) + + if !client.initialized? && opts[:configuration][:initCanFail] == false + client.close() + return [500, nil, "Failed to initialize"] + end + + clientResourceUrl = "/clients/#{clientId}" + clients[clientId] = client + return [201, {'Location' => clientResourceUrl}, nil] +end + +post '/clients/:id' do |clientId| + client = clients[clientId] + return 404 if client.nil? + + params = JSON.parse(request.body.read, :symbolize_names => true) + + client.log.info("Processing request for client #{clientId}") + client.log.debug("Parameters: #{params}") + + case params[:command] + when "evaluate" + response = client.evaluate(params[:evaluate]) + return [200, nil, response.to_json] + when "evaluateAll" + response = {:state => client.evaluate_all(params[:evaluateAll])} + return [200, nil, response.to_json] + when "customEvent" + client.track(params[:customEvent]) + return 201 + when "identifyEvent" + client.identify(params[:identifyEvent]) + return 201 + when "aliasEvent" + client.alias(params[:aliasEvent]) + return 201 + when "flushEvents" + client.flush_events + return 201 + end + + return [400, nil, {:error => "Unknown command requested"}.to_json] +end + +delete '/clients/:id' do |clientId| + client = clients[clientId] + return 404 if client.nil? + clients.delete(clientId) + client.close + + return 204 +end + +error do + env['sinatra.error'].message +end diff --git a/launchdarkly-server-sdk.gemspec b/launchdarkly-server-sdk.gemspec index 67125390..bc4492a6 100644 --- a/launchdarkly-server-sdk.gemspec +++ b/launchdarkly-server-sdk.gemspec @@ -22,7 +22,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 2.5.0" spec.add_development_dependency "aws-sdk-dynamodb", "~> 1.57" - spec.add_development_dependency "bundler", "2.2.10" + spec.add_development_dependency "bundler", "2.2.33" spec.add_development_dependency "rspec", "~> 3.10" spec.add_development_dependency "diplomat", "~> 2.4.2" spec.add_development_dependency "redis", "~> 4.2" diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index 3cfbf882..ed33e08b 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -21,6 +21,7 @@ class Config # @option opts [Integer] :capacity (10000) See {#capacity}. # @option opts [Float] :flush_interval (30) See {#flush_interval}. # @option opts [Float] :read_timeout (10) See {#read_timeout}. + # @option opts [Float] :initial_reconnect_delay (1) See {#initial_reconnect_delay}. # @option opts [Float] :connect_timeout (2) See {#connect_timeout}. # @option opts [Object] :cache_store See {#cache_store}. # @option opts [Object] :feature_store See {#feature_store}. @@ -54,6 +55,7 @@ def initialize(opts = {}) @flush_interval = opts[:flush_interval] || Config.default_flush_interval @connect_timeout = opts[:connect_timeout] || Config.default_connect_timeout @read_timeout = opts[:read_timeout] || Config.default_read_timeout + @initial_reconnect_delay = opts[:initial_reconnect_delay] || Config.default_initial_reconnect_delay @feature_store = opts[:feature_store] || Config.default_feature_store @stream = opts.has_key?(:stream) ? opts[:stream] : Config.default_stream @use_ldd = opts.has_key?(:use_ldd) ? opts[:use_ldd] : Config.default_use_ldd @@ -180,6 +182,13 @@ def offline? # attr_reader :read_timeout + # + # The initial delay before reconnecting after an error in the SSE client. + # This only applies to the streaming connection. + # @return [Float] + # + attr_reader :initial_reconnect_delay + # # The connect timeout for network connections in seconds. # @return [Float] @@ -395,6 +404,14 @@ def self.default_read_timeout 10 end + # + # The default value for {#initial_reconnect_delay}. + # @return [Float] 1 + # + def self.default_initial_reconnect_delay + 1 + end + # # The default value for {#connect_timeout}. # @return [Float] 10 diff --git a/lib/ldclient-rb/flags_state.rb b/lib/ldclient-rb/flags_state.rb index 496ad61b..50fcec88 100644 --- a/lib/ldclient-rb/flags_state.rb +++ b/lib/ldclient-rb/flags_state.rb @@ -16,21 +16,32 @@ def initialize(valid) # Used internally to build the state map. # @private - def add_flag(flag, value, variation, reason = nil, details_only_if_tracked = false) - key = flag[:key] - @flag_values[key] = value + def add_flag(flag_state, with_reasons, details_only_if_tracked) + key = flag_state[:key] + @flag_values[key] = flag_state[:value] meta = {} - with_details = !details_only_if_tracked || flag[:trackEvents] - if !with_details && flag[:debugEventsUntilDate] - with_details = flag[:debugEventsUntilDate] > Impl::Util::current_time_millis + + omit_details = false + if details_only_if_tracked + if !flag_state[:trackEvents] && !flag_state[:trackReason] && !(flag_state[:debugEventsUntilDate] && flag_state[:debugEventsUntilDate] > Impl::Util::current_time_millis) + omit_details = true + end + end + + reason = (!with_reasons and !flag_state[:trackReason]) ? nil : flag_state[:reason] + + if !reason.nil? && !omit_details + meta[:reason] = reason end - if with_details - meta[:version] = flag[:version] - meta[:reason] = reason if !reason.nil? + + if !omit_details + meta[:version] = flag_state[:version] end - meta[:variation] = variation if !variation.nil? - meta[:trackEvents] = true if flag[:trackEvents] - meta[:debugEventsUntilDate] = flag[:debugEventsUntilDate] if flag[:debugEventsUntilDate] + + meta[:variation] = flag_state[:variation] if !flag_state[:variation].nil? + meta[:trackEvents] = true if flag_state[:trackEvents] + meta[:trackReason] = true if flag_state[:trackReason] + meta[:debugEventsUntilDate] = flag_state[:debugEventsUntilDate] if flag_state[:debugEventsUntilDate] @flag_metadata[key] = meta end diff --git a/lib/ldclient-rb/impl/evaluator_operators.rb b/lib/ldclient-rb/impl/evaluator_operators.rb index 77b0960b..e54368e9 100644 --- a/lib/ldclient-rb/impl/evaluator_operators.rb +++ b/lib/ldclient-rb/impl/evaluator_operators.rb @@ -89,7 +89,7 @@ def self.user_value(user, attribute) private - BUILTINS = Set[:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous] + BUILTINS = Set[:key, :secondary, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous] NUMERIC_VERSION_COMPONENTS_REGEX = Regexp.new("^[0-9.]*") private_constant :BUILTINS diff --git a/lib/ldclient-rb/impl/event_factory.rb b/lib/ldclient-rb/impl/event_factory.rb index 691339d7..19b4e474 100644 --- a/lib/ldclient-rb/impl/event_factory.rb +++ b/lib/ldclient-rb/impl/event_factory.rb @@ -13,7 +13,7 @@ def initialize(with_reasons) end def new_eval_event(flag, user, detail, default_value, prereq_of_flag = nil) - add_experiment_data = is_experiment(flag, detail.reason) + add_experiment_data = self.class.is_experiment(flag, detail.reason) e = { kind: 'feature', key: flag[:key], @@ -91,17 +91,7 @@ def new_custom_event(event_name, user, data, metric_value) e end - private - - def context_to_context_kind(user) - if !user.nil? && user[:anonymous] - return "anonymousUser" - else - return "user" - end - end - - def is_experiment(flag, reason) + def self.is_experiment(flag, reason) return false if !reason if reason.in_experiment @@ -121,6 +111,13 @@ def is_experiment(flag, reason) false end + private def context_to_context_kind(user) + if !user.nil? && user[:anonymous] + return "anonymousUser" + else + return "user" + end + end end end end diff --git a/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb b/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb index 4085e53d..7244fc9b 100644 --- a/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +++ b/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb @@ -35,7 +35,7 @@ def initialize(table_name, opts) @client = Aws::DynamoDB::Client.new(opts[:dynamodb_opts] || {}) end - @logger.info("${description}: using DynamoDB table \"#{table_name}\"") + @logger.info("#{description}: using DynamoDB table \"#{table_name}\"") end def stop diff --git a/lib/ldclient-rb/integrations/consul.rb b/lib/ldclient-rb/integrations/consul.rb index 020c31b4..b3947047 100644 --- a/lib/ldclient-rb/integrations/consul.rb +++ b/lib/ldclient-rb/integrations/consul.rb @@ -36,7 +36,7 @@ def self.default_prefix # @option opts [Integer] :capacity (1000) maximum number of items in the cache # @return [LaunchDarkly::Interfaces::FeatureStore] a feature store object # - def self.new_feature_store(opts, &block) + def self.new_feature_store(opts = {}) core = LaunchDarkly::Impl::Integrations::Consul::ConsulFeatureStoreCore.new(opts) return LaunchDarkly::Integrations::Util::CachingStoreWrapper.new(core, opts) end diff --git a/lib/ldclient-rb/integrations/dynamodb.rb b/lib/ldclient-rb/integrations/dynamodb.rb index 229a64af..29aedcdb 100644 --- a/lib/ldclient-rb/integrations/dynamodb.rb +++ b/lib/ldclient-rb/integrations/dynamodb.rb @@ -46,7 +46,7 @@ module DynamoDB # @option opts [Integer] :capacity (1000) maximum number of items in the cache # @return [LaunchDarkly::Interfaces::FeatureStore] a feature store object # - def self.new_feature_store(table_name, opts) + def self.new_feature_store(table_name, opts = {}) core = LaunchDarkly::Impl::Integrations::DynamoDB::DynamoDBFeatureStoreCore.new(table_name, opts) LaunchDarkly::Integrations::Util::CachingStoreWrapper.new(core, opts) end diff --git a/lib/ldclient-rb/integrations/redis.rb b/lib/ldclient-rb/integrations/redis.rb index 6fed732d..95147286 100644 --- a/lib/ldclient-rb/integrations/redis.rb +++ b/lib/ldclient-rb/integrations/redis.rb @@ -58,7 +58,7 @@ def self.default_prefix # lifecycle to be independent of the SDK client # @return [LaunchDarkly::Interfaces::FeatureStore] a feature store object # - def self.new_feature_store(opts) + def self.new_feature_store(opts = {}) return RedisFeatureStore.new(opts) end diff --git a/lib/ldclient-rb/ldclient.rb b/lib/ldclient-rb/ldclient.rb index a8719773..b5e5ead9 100644 --- a/lib/ldclient-rb/ldclient.rb +++ b/lib/ldclient-rb/ldclient.rb @@ -65,7 +65,7 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5) get_segment = lambda { |key| @store.get(SEGMENTS, key) } get_big_segments_membership = lambda { |key| @big_segment_store_manager.get_user_membership(key) } @evaluator = LaunchDarkly::Impl::Evaluator.new(get_flag, get_segment, get_big_segments_membership, @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 @@ -178,7 +178,7 @@ def initialized? # Other supported user attributes include IP address, country code, and an arbitrary hash of # custom attributes. For more about the supported user properties and how they work in # LaunchDarkly, see [Targeting users](https://docs.launchdarkly.com/home/flags/targeting-users). - # + # # The optional `:privateAttributeNames` user property allows you to specify a list of # attribute names that should not be sent back to LaunchDarkly. # [Private attributes](https://docs.launchdarkly.com/home/users/attributes#creating-private-user-attributes) @@ -248,8 +248,8 @@ def variation_detail(key, user, default) # @return [void] # def identify(user) - if !user || user[:key].nil? - @config.logger.warn("Identify called with nil user or nil user key!") + if !user || user[:key].nil? || user[:key].empty? + @config.logger.warn("Identify called with nil user or empty user key!") return end sanitize_user(user) @@ -338,6 +338,15 @@ def all_flags(user) def all_flags_state(user, options={}) return FeatureFlagsState.new(false) if @config.offline? + if !initialized? + if @store.initialized? + @config.logger.warn { "Called all_flags_state before client initialization; using last known values from data store" } + else + @config.logger.warn { "Called all_flags_state before client initialization. Data store not available; returning empty state" } + return FeatureFlagsState.new(false) + end + end + unless user && !user[:key].nil? @config.logger.error { "[LDClient] User and user key must be specified in all_flags_state" } return FeatureFlagsState.new(false) @@ -359,14 +368,25 @@ def all_flags_state(user, options={}) next end begin - 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) + detail = @evaluator.evaluate(f, user, @event_factory_default).detail rescue => exn + detail = EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_EXCEPTION)) Util.log_exception(@config.logger, "Error evaluating flag \"#{k}\" in all_flags_state", exn) - state.add_flag(f, nil, nil, with_reasons ? EvaluationReason::error(EvaluationReason::ERROR_EXCEPTION) : nil, - details_only_if_tracked) end + + requires_experiment_data = EventFactory.is_experiment(f, detail.reason) + flag_state = { + key: f[:key], + value: detail.value, + variation: detail.variation_index, + reason: detail.reason, + version: f[:version], + trackEvents: f[:trackEvents] || requires_experiment_data, + trackReason: requires_experiment_data, + debugEventsUntilDate: f[:debugEventsUntilDate], + } + + state.add_flag(flag_state, with_reasons, details_only_if_tracked) end state diff --git a/lib/ldclient-rb/stream.rb b/lib/ldclient-rb/stream.rb index 64275b39..211e6321 100644 --- a/lib/ldclient-rb/stream.rb +++ b/lib/ldclient-rb/stream.rb @@ -47,7 +47,8 @@ def start headers: headers, read_timeout: READ_TIMEOUT_SECONDS, logger: @config.logger, - socket_factory: @config.socket_factory + socket_factory: @config.socket_factory, + reconnect_time: @config.initial_reconnect_delay } log_connection_started @es = SSE::Client.new(@config.stream_uri + "/all", **opts) do |conn| diff --git a/lib/ldclient-rb/util.rb b/lib/ldclient-rb/util.rb index 7bd56959..5aac9d1e 100644 --- a/lib/ldclient-rb/util.rb +++ b/lib/ldclient-rb/util.rb @@ -24,6 +24,15 @@ def self.new_http_client(uri_s, config) if config.socket_factory http_client_options["socket_class"] = config.socket_factory end + proxy = URI.parse(uri_s).find_proxy + if !proxy.nil? + http_client_options["proxy"] = { + proxy_address: proxy.host, + proxy_port: proxy.port, + proxy_username: proxy.user, + proxy_password: proxy.password + } + end return HTTP::Client.new(http_client_options) .timeout({ read: config.read_timeout, diff --git a/spec/event_sender_spec.rb b/spec/event_sender_spec.rb index 31bfb6ae..72d19197 100644 --- a/spec/event_sender_spec.rb +++ b/spec/event_sender_spec.rb @@ -14,7 +14,11 @@ module Impl let(:fake_data) { '{"things":[]}' } def make_sender(server) - subject.new(sdk_key, Config.new(events_uri: server.base_uri.to_s, logger: $null_log), nil, 0.1) + make_sender_with_events_uri(server.base_uri.to_s) + end + + def make_sender_with_events_uri(events_uri) + subject.new(sdk_key, Config.new(events_uri: events_uri, logger: $null_log), nil, 0.1) end def with_sender_and_server @@ -105,25 +109,30 @@ def with_sender_and_server end it "can use a proxy server" do - with_server do |server| - server.setup_ok_response("/bulk", "") - - with_server(StubProxyServer.new) do |proxy| - begin - ENV["http_proxy"] = proxy.base_uri.to_s + fake_target_uri = "http://request-will-not-really-go-here" + # Instead of a real proxy server, we just create a basic test HTTP server that + # pretends to be a proxy. The proof that the proxy logic is working correctly is + # that the request goes to that server, instead of to fake_target_uri. We can't + # use a real proxy that really forwards requests to another test server, because + # that test server would be at localhost, and proxy environment variables are + # ignored if the target is localhost. + with_server do |proxy| + proxy.setup_ok_response("/bulk", "") - es = make_sender(server) + begin + ENV["http_proxy"] = proxy.base_uri.to_s - result = es.send_event_data(fake_data, "", false) - - expect(result.success).to be true + es = make_sender_with_events_uri(fake_target_uri) - req, body = server.await_request_with_body - expect(body).to eq fake_data - ensure - ENV["http_proxy"] = nil - end + result = es.send_event_data(fake_data, "", false) + + expect(result.success).to be true + ensure + ENV["http_proxy"] = nil end + + req, body = proxy.await_request_with_body + expect(body).to eq fake_data end end diff --git a/spec/flags_state_spec.rb b/spec/flags_state_spec.rb index bda55b11..323c6c31 100644 --- a/spec/flags_state_spec.rb +++ b/spec/flags_state_spec.rb @@ -6,8 +6,8 @@ it "can get flag value" do state = subject.new(true) - flag = { key: 'key' } - state.add_flag(flag, 'value', 1) + flag_state = { key: 'key', value: 'value', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + state.add_flag(flag_state, false, false) expect(state.flag_value('key')).to eq 'value' end @@ -20,21 +20,21 @@ it "can be converted to values map" do state = subject.new(true) - flag1 = { key: 'key1' } - flag2 = { key: 'key2' } - state.add_flag(flag1, 'value1', 0) - state.add_flag(flag2, 'value2', 1) + flag_state1 = { key: 'key1', value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + flag_state2 = { key: 'key2', value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) expect(state.values_map).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) end it "can be converted to JSON structure" do state = subject.new(true) - flag1 = { key: "key1", version: 100, offVariation: 0, variations: [ 'value1' ], trackEvents: false } - flag2 = { key: "key2", version: 200, offVariation: 1, variations: [ 'x', 'value2' ], trackEvents: true, debugEventsUntilDate: 1000 } - state.add_flag(flag1, 'value1', 0) - state.add_flag(flag2, 'value2', 1) - + flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) + result = state.as_json expect(result).to eq({ 'key1' => 'value1', @@ -57,11 +57,11 @@ it "can be converted to JSON string" do state = subject.new(true) - flag1 = { key: "key1", version: 100, offVariation: 0, variations: [ 'value1' ], trackEvents: false } - flag2 = { key: "key2", version: 200, offVariation: 1, variations: [ 'x', 'value2' ], trackEvents: true, debugEventsUntilDate: 1000 } - state.add_flag(flag1, 'value1', 0) - state.add_flag(flag2, 'value2', 1) - + flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) + object = state.as_json str = state.to_json expect(object.to_json).to eq(str) @@ -69,11 +69,11 @@ it "uses our custom serializer with JSON.generate" do state = subject.new(true) - flag1 = { key: "key1", version: 100, offVariation: 0, variations: [ 'value1' ], trackEvents: false } - flag2 = { key: "key2", version: 200, offVariation: 1, variations: [ 'x', 'value2' ], trackEvents: true, debugEventsUntilDate: 1000 } - state.add_flag(flag1, 'value1', 0) - state.add_flag(flag2, 'value2', 1) - + flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: LaunchDarkly::EvaluationReason.fallthrough(false) } + state.add_flag(flag_state1, false, false) + state.add_flag(flag_state2, false, false) + stringFromToJson = state.to_json stringFromGenerate = JSON.generate(state) expect(stringFromGenerate).to eq(stringFromToJson) diff --git a/spec/impl/evaluator_operators_spec.rb b/spec/impl/evaluator_operators_spec.rb index ddf55cc7..5c447e6f 100644 --- a/spec/impl/evaluator_operators_spec.rb +++ b/spec/impl/evaluator_operators_spec.rb @@ -105,13 +105,13 @@ end describe "user_value" do - [:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous, :some_custom_attr].each do |attr| + [:key, :secondary, :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| + [:key, :secondary, :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 diff --git a/spec/ldclient_evaluation_spec.rb b/spec/ldclient_evaluation_spec.rb index c63cb882..581f3256 100644 --- a/spec/ldclient_evaluation_spec.rb +++ b/spec/ldclient_evaluation_spec.rb @@ -301,6 +301,22 @@ module LaunchDarkly expect(state.values_map).to eq({}) end end + + it "returns empty state if store is not initialize" do + wait = double + expect(wait).to receive(:wait).at_least(:once) + + source = double + expect(source).to receive(:start).at_least(:once).and_return(wait) + expect(source).to receive(:stop).at_least(:once).and_return(wait) + expect(source).to receive(:initialized?).at_least(:once).and_return(false) + store = LaunchDarkly::InMemoryFeatureStore.new + with_client(test_config(store: store, data_source: source)) do |offline_client| + state = offline_client.all_flags_state({ key: 'userkey' }) + expect(state.valid?).to be false + expect(state.values_map).to eq({}) + end + end end end end diff --git a/spec/ldclient_events_spec.rb b/spec/ldclient_events_spec.rb index 86eaa77d..b2afcc13 100644 --- a/spec/ldclient_events_spec.rb +++ b/spec/ldclient_events_spec.rb @@ -196,13 +196,13 @@ def event_processor(client) end end - it "does not send event, and logs warning, if user key is nil" do + it "does not send event, and logs warning, if user key is blank" do logger = double().as_null_object with_client(test_config(logger: logger)) do |client| expect(event_processor(client)).not_to receive(:add_event) expect(logger).to receive(:warn) - client.identify({ key: nil }) + client.identify({ key: "" }) end end end diff --git a/spec/requestor_spec.rb b/spec/requestor_spec.rb index c224b22a..65ec7ed3 100644 --- a/spec/requestor_spec.rb +++ b/spec/requestor_spec.rb @@ -189,19 +189,24 @@ def with_requestor(base_uri, opts = {}) end it "can use a proxy server" do + fake_target_uri = "http://request-will-not-really-go-here" + # Instead of a real proxy server, we just create a basic test HTTP server that + # pretends to be a proxy. The proof that the proxy logic is working correctly is + # that the request goes to that server, instead of to fake_target_uri. We can't + # use a real proxy that really forwards requests to another test server, because + # that test server would be at localhost, and proxy environment variables are + # ignored if the target is localhost. expected_data = { flags: { flagkey: { key: "flagkey" } } } - with_server do |server| - 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(LaunchDarkly::Impl::Model.make_all_store_data(expected_data)) - end - ensure - ENV["http_proxy"] = nil + with_server do |proxy| + proxy.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json", { "etag" => "x" }) + begin + ENV["http_proxy"] = proxy.base_uri.to_s + with_requestor(fake_target_uri) do |requestor| + data = requestor.request_all_data + expect(data).to eq(LaunchDarkly::Impl::Model.make_all_store_data(expected_data)) end + ensure + ENV["http_proxy"] = nil end end end