Skip to content

Commit

Permalink
Merge 69d1813 into 5ec1915
Browse files Browse the repository at this point in the history
  • Loading branch information
sighphyre committed Feb 11, 2022
2 parents 5ec1915 + 69d1813 commit 9322fb8
Show file tree
Hide file tree
Showing 20 changed files with 606 additions and 39 deletions.
66 changes: 63 additions & 3 deletions README.md
Expand Up @@ -80,17 +80,22 @@ 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` |
`bootstrap_config` | Bootstrap config on how to loaded data on start-up. This is useful for loading large states on startup without (or before) hitting the network. | N | Unleash::Bootstrap::Configuration | `nil` |

For in a more in depth look, please see `lib/unleash/configuration.rb`.
For a more in-depth look, please see `lib/unleash/configuration.rb`.

Environment Variable | Description
---------|---------
`UNLEASH_BOOTSTRAP_FILE` | File to read bootstrap data from
`UNLEASH_BOOTSTRAP_URL` | URL to read bootstrap data from

## Usage in a plain Ruby Application

```ruby
require 'unleash'
require 'unleash/context'

@unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: 'http://unleash.herokuapp.com/api', custom_http_headers: {'Authorization': '<API token>'})
@unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: 'http://unleash.herokuapp.com/api', custom_http_headers: { 'Authorization': '<API token>' })

feature_name = "AwesomeFeature"
unleash_context = Unleash::Context.new
Expand Down Expand Up @@ -268,6 +273,62 @@ variant = UNLEASH.get_variant "ColorVariants", @unleash_context, fallback_varian
puts "variant color is: #{variant.payload.fetch('color')}"
```

## Bootstrapping

Bootstrap configuration allows the client to be initialized with a predefined set of toggle states. Bootstrapping can be configured by providing a bootstrap configuration when initializing the client.
```ruby
@unleash = Unleash::Client.new(
url: 'http://unleash.herokuapp.com/api',
app_name: 'my_ruby_app',
custom_http_headers: { 'Authorization': '<API token>' },
bootstrap_config: Unleash::Bootstrap::Configuration.new({
url: "http://unleash.herokuapp.com/api/client/features",
url_headers: {'Authorization': '<API token>'}
})
)
```
The `Bootstrap::Configuration` initializer takes a hash with one of the following options specified:

* `file_path` - An absolute or relative path to a file containing a JSON string of the response body from the Unleash server. This can also be set though the `UNLEASH_BOOTSTRAP_FILE` environment variable.
* `url` - A url pointing to an Unleash server's features endpoint, the code sample above is illustrative. This can also be set though the `UNLEASH_BOOTSTRAP_URL` environment variable.
* `url_headers` - Headers for the GET http request to the `url` above. Only used if the `url` parameter is also set. If this option isn't set then the bootstrapper will use the same url headers as the Unleash client.
* `data` - A raw JSON string as returned by the Unleash server.
* `block` - A lambda containing custom logic if you need it, an example is provided below.

You should only specify one type of bootstrapping since only one will be invoked and the others will be ignored. The order of preference is as follows:

- Select a data bootstrapper if it exists.
- If no data bootstrapper exists, select the block bootstrapper.
- If no block bootstrapper exists, select the file bootstrapper from either parameters or the specified environment variable.
- If no file bootstrapper exists, then check for a URL bootstrapper from either the parameters or the specified environment variable.


Example usage:

First saving the toggles locally:
```shell
curl -H 'Authorization: <API token>' -XGET 'http://unleash.herokuapp.com/api' > ./default-toggles.json
```

Now using them on start up:

```ruby

custom_boostrapper = lambda {
File.read('./default-toggles.json')
}

@unleash = Unleash::Client.new(
app_name: 'my_ruby_app',
url: 'http://unleash.herokuapp.com/api',
custom_http_headers: { 'Authorization': '<API token>' },
bootstrap_config: Unleash::Bootstrap::Configuration.new({
block: custom_boostrapper
}
)
```

This example could be easily achieved with a file bootstrapper, this is just to illustrate the usage of custom bootstrapping. Be aware that the client initializer will block until bootstrapping is complete.

#### Client methods

Expand Down Expand Up @@ -306,7 +367,6 @@ This client comes with the all the required strategies out of the box:
* UnknownStrategy
* UserWithIdStrategy


## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
Expand Down
51 changes: 51 additions & 0 deletions examples/bootstrap.rb
@@ -0,0 +1,51 @@
#!/usr/bin/env ruby

require 'unleash'
require 'unleash/context'
require 'unleash/bootstrap/configuration'

puts ">> START bootstrap.rb"

@unleash = Unleash::Client.new(
url: 'http://unleash.herokuapp.com/api',
custom_http_headers: { 'Authorization': '943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0' },
app_name: 'bootstrap-test',
instance_id: 'local-test-cli',
refresh_interval: 2,
disable_client: true,
disable_metrics: true,
metrics_interval: 2,
retry_limit: 2,
bootstrap_config: Unleash::Bootstrap::Configuration.new(file_path: "examples/default-toggles.json")
)

feature_name = "featureX"
unleash_context = Unleash::Context.new
unleash_context.user_id = 123

sleep 1
3.times do
if @unleash.is_enabled?(feature_name, unleash_context)
puts "> #{feature_name} is enabled"
else
puts "> #{feature_name} is not enabled"
end
sleep 1
puts "---"
puts ""
puts ""
end

sleep 3
feature_name = "foobar"
if @unleash.is_enabled?(feature_name, unleash_context, true)
puts "> #{feature_name} is enabled"
else
puts "> #{feature_name} is not enabled"
end

puts "> shutting down client..."

@unleash.shutdown

puts ">> END bootstrap.rb"
42 changes: 42 additions & 0 deletions examples/default-toggles.json
@@ -0,0 +1,42 @@
{
"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"
}
}
]
}
]
}

25 changes: 25 additions & 0 deletions lib/unleash/bootstrap/configuration.rb
@@ -0,0 +1,25 @@
module Unleash
module Bootstrap
class Configuration
attr_accessor :data, :file_path, :url, :url_headers, :block

def initialize(opts = {})
self.file_path = resolve_value_indifferently(opts, 'file_path') || ENV['UNLEASH_BOOTSTRAP_FILE'] || nil
self.url = resolve_value_indifferently(opts, 'url') || ENV['UNLEASH_BOOTSTRAP_URL'] || nil
self.url_headers = resolve_value_indifferently(opts, 'url_headers')
self.data = resolve_value_indifferently(opts, 'data')
self.block = resolve_value_indifferently(opts, 'block')
end

def valid?
![self.data, self.file_path, self.url, self.block].all?(&:nil?)
end

private

def resolve_value_indifferently(opts, key)
opts[key] || opts[key.to_sym]
end
end
end
end
22 changes: 22 additions & 0 deletions lib/unleash/bootstrap/handler.rb
@@ -0,0 +1,22 @@
require 'unleash/bootstrap/provider/from_url'
require 'unleash/bootstrap/provider/from_file'

module Unleash
module Bootstrap
class Handler
attr_accessor :configuration

def initialize(configuration)
self.configuration = configuration
end

# @return [String] JSON string representing data returned from an Unleash server
def retrieve_toggles
return configuration.data unless self.configuration.data.nil?
return configuration.block.call if self.configuration.block.is_a?(Proc)
return Provider::FromFile.read(configuration.file_path) unless self.configuration.file_path.nil?
return Provider::FromUrl.read(configuration.url, configuration.url_headers) unless self.configuration.url.nil?
end
end
end
end
14 changes: 14 additions & 0 deletions lib/unleash/bootstrap/provider/base.rb
@@ -0,0 +1,14 @@
module Unleash
module Bootstrap
module Provider
class NotImplemented < RuntimeError
end

class Base
def read
raise NotImplemented, "Bootstrap is not implemented"
end
end
end
end
end
14 changes: 14 additions & 0 deletions lib/unleash/bootstrap/provider/from_file.rb
@@ -0,0 +1,14 @@
require 'unleash/bootstrap/provider/base'

module Unleash
module Bootstrap
module Provider
class FromFile < Base
# @param file_path [String]
def self.read(file_path)
File.read(file_path)
end
end
end
end
end
19 changes: 19 additions & 0 deletions lib/unleash/bootstrap/provider/from_url.rb
@@ -0,0 +1,19 @@
require 'unleash/bootstrap/provider/base'

module Unleash
module Bootstrap
module Provider
class FromUrl < Base
# @param url [String]
# @param headers [Hash, nil] HTTP headers to use. If not set, the unleash client SDK ones will be used.
def self.read(url, headers = nil)
response = Unleash::Util::Http.get(URI.parse(url), nil, headers)

return nil if response.code != '200'

response.body
end
end
end
end
end
16 changes: 8 additions & 8 deletions lib/unleash/client.rb
Expand Up @@ -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

Expand All @@ -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?
Expand Down Expand Up @@ -121,11 +117,11 @@ 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,
Unleash.configuration.retry_limit
Unleash.configuration.retry_limit,
first_fetch_is_eager
)
self.fetcher_scheduled_executor.run do
Unleash.toggle_fetcher.fetch
Expand Down Expand Up @@ -161,5 +157,9 @@ def register
def disabled_variant
@disabled_variant ||= Unleash::FeatureToggle.disabled_variant
end

def first_fetch_is_eager
Unleash.configuration.use_bootstrap?
end
end
end
9 changes: 8 additions & 1 deletion lib/unleash/configuration.rb
@@ -1,5 +1,6 @@
require 'securerandom'
require 'tmpdir'
require 'unleash/bootstrap/configuration'

module Unleash
class Configuration
Expand All @@ -18,7 +19,8 @@ class Configuration
:metrics_interval,
:backup_file,
:logger,
:log_level
:log_level,
:bootstrap_config

def initialize(opts = {})
ensure_valid_opts(opts)
Expand Down Expand Up @@ -70,6 +72,10 @@ def url_stripped_of_slash
self.url.delete_suffix '/'
end

def use_bootstrap?
self.bootstrap_config&.valid?
end

private

def ensure_valid_opts(opts)
Expand All @@ -92,6 +98,7 @@ def set_defaults
self.retry_limit = 5
self.backup_file = nil
self.log_level = Logger::WARN
self.bootstrap_config = nil

self.custom_http_headers = {}
end
Expand Down
7 changes: 5 additions & 2 deletions lib/unleash/scheduled_executor.rb
@@ -1,19 +1,22 @@
module Unleash
class ScheduledExecutor
attr_accessor :name, :interval, :max_exceptions, :retry_count, :thread
attr_accessor :name, :interval, :max_exceptions, :retry_count, :thread, :immediate_execution

def initialize(name, interval, max_exceptions = 5)
def initialize(name, interval, max_exceptions = 5, immediate_execution = false)
self.name = name || ''
self.interval = interval
self.max_exceptions = max_exceptions
self.retry_count = 0
self.thread = nil
self.immediate_execution = immediate_execution
end

def run(&blk)
self.thread = Thread.new do
Thread.current[:name] = self.name

run_blk{ blk.call } if self.immediate_execution

Unleash.logger.debug "thread #{name} loop starting"
loop do
Unleash.logger.debug "thread #{name} sleeping for #{interval} seconds"
Expand Down

0 comments on commit 9322fb8

Please sign in to comment.