diff --git a/README.md b/README.md index 7dfff2b4..1841792f 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Argument | Description | Required? | Type | Default Value| `backup_file` | Filename to store the last known state from the Unleash server. Best to not change this from the default. | N | String | `Dir.tmpdir + "/unleash-#{app_name}-repo.json` | `logger` | Specify a custom `Logger` class to handle logs for the Unleash client. | N | Class | `Logger.new(STDOUT)` | `log_level` | Change the log level for the `Logger` class. Constant from `Logger::Severity`. | N | Constant | `Logger::WARN` | -`bootstrapper` | Bootstrapper object to get a list of toggles on load before it reads them from the unleash server. This is useful for loading large states on startup without hitting the network. Bootstrapping classes are provided for URL and file reading but you can implement your own for other sources of toggles. | N | Unleash::Bootstrap::Base or Nil | `nil` | +`bootstrap_data` | Bootstrap data to be loaded on start-up. This is useful for loading large states on startup without (or before) hitting the network. | N | String | `nil` | For in a more in depth look, please see `lib/unleash/configuration.rb`. @@ -89,14 +89,8 @@ For in a more in depth look, please see `lib/unleash/configuration.rb`. ```ruby require 'unleash' require 'unleash/context' -require 'unleash/bootstrap' -@unleash = Unleash::Client.new( - app_name: 'my_ruby_app', - url: 'http://unleash.herokuapp.com/api', - custom_http_headers: { 'Authorization': '' }, - bootstrapper: Unleash::Bootstrap::FromFile.new('./default-toggles.json') - ) +@unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: 'http://unleash.herokuapp.com/api', custom_http_headers: { 'Authorization': '' }) feature_name = "AwesomeFeature" unleash_context = Unleash::Context.new @@ -116,8 +110,6 @@ end Put in `config/initializers/unleash.rb`: ```ruby -require 'unleash/bootstrap' - Unleash.configure do |config| config.app_name = Rails.application.class.parent.to_s config.url = 'http://unleash.herokuapp.com/api' @@ -276,6 +268,27 @@ variant = UNLEASH.get_variant "ColorVariants", @unleash_context, fallback_varian puts "variant color is: #{variant.payload.fetch('color')}" ``` +#### Bootstrapping + +`bootstrap_data` configuration allows the client to be initialized with a predefined set of toggle states. +The content of the parameter is a JSON string containing the response body from the unleash server. + +We provide two classes to help fetch the bootstrap files: +* `Unleash::Bootstrap::FromFile` +* `Unleash::Bootstrap::FromUri` + +Example usage: +```ruby +@unleash = Unleash::Client.new( + app_name: 'my_ruby_app', + url: 'http://unleash.herokuapp.com/api', + custom_http_headers: { 'Authorization': '' }, + bootstrap_data: Unleash::Bootstrap::FromFile.new('./default-toggles.json').read + # or + # bootstrap_data: Unleash::Bootstrap::FromUri.new('https://example.com/unleash-default-toggles.json').read +) + +``` #### Client methods diff --git a/examples/bootstrap.rb b/examples/bootstrap.rb index 75427e1c..e1707627 100755 --- a/examples/bootstrap.rb +++ b/examples/bootstrap.rb @@ -14,7 +14,7 @@ refresh_interval: 2, metrics_interval: 2, retry_limit: 2, - bootstrapper: Unleash::Bootstrap::FromFile.new('./examples/default-toggles.json') + bootstrap_data: Unleash::Bootstrap::FromFile.new('./examples/default-toggles.json').read ) feature_name = "featureX" diff --git a/lib/unleash/bootstrap/base.rb b/lib/unleash/bootstrap/base.rb index ceb090b5..1d513025 100644 --- a/lib/unleash/bootstrap/base.rb +++ b/lib/unleash/bootstrap/base.rb @@ -7,12 +7,6 @@ class Base def read raise NotImplemented, "Bootstrap is not implemented" end - - def extract_features(bootstrap_hash) - raise NotImplemented, "The provided bootstrap data doesn't seem to have a valid set of toggles" if bootstrap_hash['version'] < 1 - - bootstrap_hash['features'] - end end end end diff --git a/lib/unleash/bootstrap/from_file.rb b/lib/unleash/bootstrap/from_file.rb index 2995a188..5266464e 100644 --- a/lib/unleash/bootstrap/from_file.rb +++ b/lib/unleash/bootstrap/from_file.rb @@ -1,17 +1,9 @@ module Unleash module Bootstrap class FromFile < Base - attr_accessor :file_path - # @param file_path [String] - def initialize(file_path) - self.file_path = file_path - end - - def read - file_content = File.read(self.file_path) - bootstrap_hash = JSON.parse(file_content) - extract_features(bootstrap_hash) + def self.read(file_path) + File.read(file_path) end end end diff --git a/lib/unleash/bootstrap/from_uri.rb b/lib/unleash/bootstrap/from_uri.rb index 64f58aaa..46e8407d 100644 --- a/lib/unleash/bootstrap/from_uri.rb +++ b/lib/unleash/bootstrap/from_uri.rb @@ -1,19 +1,14 @@ module Unleash module Bootstrap class FromUri < Base - attr_accessor :uri, :headers - - # @param uri [String] + # @param url [String] # @param headers [Hash, nil] HTTP headers to use. If not set, the unleash client SDK ones will be used. - def initialize(uri, headers = nil) - self.uri = URI(uri) - self.headers = headers - end + def self.read(url, headers = nil) + response = Unleash::Util::Http.get(URI.parse(url), nil, headers) + + return nil if response.code != '200' - def read - response = Unleash::Util::Http.get(self.uri, nil, self.headers) - bootstrap_hash = JSON.parse(response.body) - extract_features(bootstrap_hash) + response.body end end end diff --git a/lib/unleash/client.rb b/lib/unleash/client.rb index 1c7c9ed8..68f64fc1 100644 --- a/lib/unleash/client.rb +++ b/lib/unleash/client.rb @@ -18,8 +18,9 @@ def initialize(*opts) Unleash.logger = Unleash.configuration.logger.clone Unleash.logger.level = Unleash.configuration.log_level + Unleash.toggle_fetcher = Unleash::ToggleFetcher.new if Unleash.configuration.disable_client - Unleash.logger.warn "Unleash::Client is disabled! Will only return default results!" + Unleash.logger.warn "Unleash::Client is disabled! Will only return default (or bootstrapped if available) results!" return end @@ -37,11 +38,6 @@ def is_enabled?(feature, context = nil, default_value_param = false, &fallback_b default_value_param end - if Unleash.configuration.disable_client - Unleash.logger.warn "unleash_client is disabled! Always returning #{default_value} for feature #{feature}!" - return default_value - end - toggle_as_hash = Unleash&.toggles&.select{ |toggle| toggle['name'] == feature }&.first if toggle_as_hash.nil? @@ -121,7 +117,6 @@ def info end def start_toggle_fetcher - Unleash.toggle_fetcher = Unleash::ToggleFetcher.new self.fetcher_scheduled_executor = Unleash::ScheduledExecutor.new( 'ToggleFetcher', Unleash.configuration.refresh_interval, diff --git a/lib/unleash/configuration.rb b/lib/unleash/configuration.rb index bf7c9e1a..3e836af9 100644 --- a/lib/unleash/configuration.rb +++ b/lib/unleash/configuration.rb @@ -19,7 +19,7 @@ class Configuration :backup_file, :logger, :log_level, - :bootstrapper + :bootstrap_data def initialize(opts = {}) ensure_valid_opts(opts) @@ -93,7 +93,7 @@ def set_defaults self.retry_limit = 5 self.backup_file = nil self.log_level = Logger::WARN - self.bootstrapper = nil + self.bootstrap_data = nil self.custom_http_headers = {} end diff --git a/lib/unleash/toggle_fetcher.rb b/lib/unleash/toggle_fetcher.rb index 558061d8..9c4827ab 100755 --- a/lib/unleash/toggle_fetcher.rb +++ b/lib/unleash/toggle_fetcher.rb @@ -4,7 +4,7 @@ module Unleash class ToggleFetcher - attr_accessor :toggle_cache, :toggle_lock, :toggle_resource, :etag, :retry_count, :bootstrapper + attr_accessor :toggle_cache, :toggle_lock, :toggle_resource, :etag, :retry_count def initialize self.etag = nil @@ -12,19 +12,16 @@ def initialize self.toggle_lock = Mutex.new self.toggle_resource = ConditionVariable.new self.retry_count = 0 - self.bootstrapper = Unleash.configuration.bootstrapper - # start by fetching synchronously, and failing back to reading the backup file. begin - if self.bootstrapper.nil? - fetch - else - # if the consumer provides a bootstrapper, use it! - synchronize_with_local_cache! self.bootstrapper.read - update_running_client! - end + # if bootstrap configuration is available, initialize + bootstrap unless Unleash.configuration.bootstrap_data.nil? + + # if the client is enabled, fetch synchronously + fetch unless Unleash.configuration.disable_client rescue StandardError => e - Unleash.logger.warn "ToggleFetcher was unable to fetch from the network or bootstrap, attempting to read from backup file." + # fail back to reading the backup file + Unleash.logger.warn "ToggleFetcher was unable to fetch from the network, attempting to read from backup file." Unleash.logger.debug "Exception Caught: #{e}" read! end @@ -43,6 +40,8 @@ def toggles # rename to refresh_from_server! ?? def fetch Unleash.logger.debug "fetch()" + return if Unleash.configuration.disable_client + response = Unleash::Util::Http.get(Unleash.configuration.fetch_toggles_uri, etag) if response.code == '304' @@ -53,14 +52,7 @@ def fetch end self.etag = response['ETag'] - response_hash = JSON.parse(response.body) - - if response_hash['version'] >= 1 - features = response_hash['features'] - else - raise NotImplemented, "Version of features provided by unleash server" \ - " is unsupported by this client." - end + features = get_features(response.body) # always synchronize with the local cache when fetching: synchronize_with_local_cache!(features) @@ -133,5 +125,24 @@ def read! file&.close end end + + def bootstrap + features = get_features(Unleash.configuration.bootstrap_data) + + synchronize_with_local_cache! features + update_running_client! + + # reset Unleash.configuration.bootstrap_data to free up memory, as we will never use it again + Unleash.configuration.bootstrap_data = nil + end + + # @param response_body [String] + def get_features(response_body) + response_hash = JSON.parse(response_body) + return response_hash['features'] if response_hash['version'] >= 1 + + raise NotImplemented, "Version of features provided by unleash server" \ + " is unsupported by this client." + end end end diff --git a/spec/unleash/bootstrap-resources/features-v1.json b/spec/unleash/bootstrap-resources/features-v1.json index 721ebe83..8fc83862 100644 --- a/spec/unleash/bootstrap-resources/features-v1.json +++ b/spec/unleash/bootstrap-resources/features-v1.json @@ -1,43 +1,14 @@ - { - "version": 1, - "features": [ + "version": 1, + "features": [ { - "name": "featureX", - "enabled": true, - "strategies": [ - { - "name": "default" - } - ] - }, - { - "name": "featureY", - "enabled": false, - "strategies": [ - { - "name": "baz", - "parameters": { - "foo": "bar" - } - } - ] - - }, - { - "name": "featureZ", - "enabled": true, - "strategies": [ - { - "name": "default" - }, - { - "name": "hola", - "parameters": { - "name": "val" - } - } - ] - + "name": "featureX", + "enabled": true, + "strategies": [ + { + "name": "default" + } + ] } -]} \ No newline at end of file + ] +} diff --git a/spec/unleash/bootstrap/from_file_spec.rb b/spec/unleash/bootstrap/from_file_spec.rb index 2d56b20b..6a71f944 100644 --- a/spec/unleash/bootstrap/from_file_spec.rb +++ b/spec/unleash/bootstrap/from_file_spec.rb @@ -13,10 +13,12 @@ it 'loads bootstrap toggle correctly from file' do bootstrap_file = './spec/unleash/bootstrap-resources/features-v1.json' - bootstrapper = Unleash::Bootstrap::FromFile.new(bootstrap_file) + bootstrap_contents = Unleash::Bootstrap::FromFile.read(bootstrap_file) + bootstrap_features = JSON.parse(bootstrap_contents)['features'] + file_contents = File.open(bootstrap_file).read file_features = JSON.parse(file_contents)['features'] - expect(bootstrapper.read).to include_json(file_features) + expect(bootstrap_features).to include_json(file_features) end end diff --git a/spec/unleash/bootstrap/from_uri_spec.rb b/spec/unleash/bootstrap/from_uri_spec.rb index ef2edd09..5a4fb74b 100644 --- a/spec/unleash/bootstrap/from_uri_spec.rb +++ b/spec/unleash/bootstrap/from_uri_spec.rb @@ -19,8 +19,9 @@ ) .to_return(status: 200, body: file_contents, headers: {}) - bootstrapper = Unleash::Bootstrap::FromUri.new('http://test-url/bootstrap-goodness', {}) + bootstrap_contents = Unleash::Bootstrap::FromUri.read('http://test-url/bootstrap-goodness', {}) + bootstrap_features = JSON.parse(bootstrap_contents)['features'] - expect(bootstrapper.read).to include_json(file_features) + expect(bootstrap_features).to include_json(file_features) end end diff --git a/spec/unleash/client_spec.rb b/spec/unleash/client_spec.rb index cbdefea7..0e5b6026 100644 --- a/spec/unleash/client_spec.rb +++ b/spec/unleash/client_spec.rb @@ -168,12 +168,7 @@ config.log_level = Logger::DEBUG end - unleash_client = Unleash::Client.new( - url: 'http://test-url/', - app_name: 'my-test-app', - instance_id: 'rspec/test', - custom_http_headers: { 'X-API-KEY' => '123' } - ) + unleash_client = Unleash::Client.new expect( unleash_client.is_enabled?('toggleName', {}, true) @@ -184,6 +179,41 @@ expect(WebMock).to have_requested(:get, 'http://test-url/client/features') end + it "should load/use correct variants from a bootstrap source" do + bootstrap_values = '{ + "version": 1, + "features": [ + { + "name": "featureX", + "enabled": true, + "strategies": [{ "name": "default" }] + } + ] + }' + + Unleash.configure do |config| + config.url = 'http://test-url/' + config.app_name = 'my-test-app' + config.instance_id = 'rspec/test' + config.disable_client = true + config.disable_metrics = true + config.custom_http_headers = { 'X-API-KEY' => '123' } + config.log_level = Logger::DEBUG + config.bootstrap_data = bootstrap_values + end + + expect(Unleash.configuration.bootstrap_data).to eq(bootstrap_values) + + unleash_client = Unleash::Client.new + expect( + unleash_client.is_enabled?('featureX', {}, false) + ).to be true + + expect(WebMock).not_to have_requested(:get, 'http://test-url/') + expect(WebMock).not_to have_requested(:post, 'http://test-url/client/register') + expect(WebMock).not_to have_requested(:get, 'http://test-url/client/features') + end + it "should not fail if we are provided no toggles from the unleash server" do WebMock.stub_request(:post, "http://test-url/client/register") .with( @@ -215,16 +245,12 @@ config.url = 'http://test-url/' config.app_name = 'my-test-app' config.instance_id = 'rspec/test' + config.disable_client = false config.disable_metrics = true config.custom_http_headers = { 'X-API-KEY' => '123' } end - unleash_client = Unleash::Client.new( - url: 'http://test-url/', - app_name: 'my-test-app', - instance_id: 'rspec/test', - custom_http_headers: { 'X-API-KEY' => '123' } - ) + unleash_client = Unleash::Client.new expect( unleash_client.is_enabled?('any_feature', {}, true)