Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement custom bootstrapping on startup #85

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Expand Up @@ -79,6 +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` |
`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`.

Expand All @@ -89,7 +90,7 @@ For in a more in depth look, please see `lib/unleash/configuration.rb`.
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 @@ -267,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:
sighphyre marked this conversation as resolved.
Show resolved Hide resolved
```ruby
@unleash = Unleash::Client.new(
app_name: 'my_ruby_app',
url: 'http://unleash.herokuapp.com/api',
custom_http_headers: { 'Authorization': '<API token>' },
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

Expand Down Expand Up @@ -305,6 +327,12 @@ This client comes with the all the required strategies out of the box:
* UnknownStrategy
* UserWithIdStrategy

## Available Bootstrap Classes

This client comes with these classes to load unleash features on startup, before making a request to the Unleash API:

* Unleash::Bootstrap::FromFile
* Unleash::Bootstrap::FromUri
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add examples, including how to set HTTP headers.


## Development

Expand Down
49 changes: 49 additions & 0 deletions examples/bootstrap.rb
@@ -0,0 +1,49 @@
#!/usr/bin/env ruby

require 'unleash'
require 'unleash/context'
require 'unleash/bootstrap/from_file'

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,
metrics_interval: 2,
retry_limit: 2,
bootstrap_data: Unleash::Bootstrap::FromFile.new('./examples/default-toggles.json').read
)

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"
43 changes: 43 additions & 0 deletions examples/default-toggles.json
@@ -0,0 +1,43 @@

rarruda marked this conversation as resolved.
Show resolved Hide resolved
{
"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"
}
}
]

}
]}
rarruda marked this conversation as resolved.
Show resolved Hide resolved
12 changes: 12 additions & 0 deletions lib/unleash/bootstrap/base.rb
@@ -0,0 +1,12 @@
module Unleash
module Bootstrap
class NotImplemented < RuntimeError
end

class Base
def read
raise NotImplemented, "Bootstrap is not implemented"
end
end
end
end
10 changes: 10 additions & 0 deletions lib/unleash/bootstrap/from_file.rb
@@ -0,0 +1,10 @@
module Unleash
module Bootstrap
class FromFile < Base
# @param file_path [String]
def self.read(file_path)
File.read(file_path)
end
end
end
end
15 changes: 15 additions & 0 deletions lib/unleash/bootstrap/from_uri.rb
@@ -0,0 +1,15 @@
module Unleash
module Bootstrap
class FromUri < 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
9 changes: 2 additions & 7 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,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,
Expand Down
4 changes: 3 additions & 1 deletion lib/unleash/configuration.rb
Expand Up @@ -18,7 +18,8 @@ class Configuration
:metrics_interval,
:backup_file,
:logger,
:log_level
:log_level,
:bootstrap_data

def initialize(opts = {})
ensure_valid_opts(opts)
Expand Down Expand Up @@ -92,6 +93,7 @@ def set_defaults
self.retry_limit = 5
self.backup_file = nil
self.log_level = Logger::WARN
self.bootstrap_data = nil

self.custom_http_headers = {}
end
Expand Down
38 changes: 28 additions & 10 deletions lib/unleash/toggle_fetcher.rb
Expand Up @@ -13,10 +13,14 @@ def initialize
self.toggle_resource = ConditionVariable.new
self.retry_count = 0

# start by fetching synchronously, and failing back to reading the backup file.
begin
fetch
# 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
# 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!
Expand All @@ -36,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'
Expand All @@ -46,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)
Expand Down Expand Up @@ -126,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
9 changes: 6 additions & 3 deletions lib/unleash/util/http.rb
Expand Up @@ -4,10 +4,10 @@
module Unleash
module Util
module Http
def self.get(uri, etag = nil)
def self.get(uri, etag = nil, headers_override = nil)
http = http_connection(uri)

request = Net::HTTP::Get.new(uri.request_uri, http_headers(etag))
request = Net::HTTP::Get.new(uri.request_uri, http_headers(etag, headers_override))

http.request(request)
end
Expand All @@ -30,10 +30,13 @@ def self.http_connection(uri)
http
end

def self.http_headers(etag = nil)
# @param etag [String, nil]
# @param headers_override [Hash, nil]
def self.http_headers(etag = nil, headers_override = nil)
Unleash.logger.debug "ETag: #{etag}" unless etag.nil?

headers = (Unleash.configuration.http_headers || {}).dup
headers = headers_override if headers_override.is_a?(Hash)
headers['Content-Type'] = 'application/json'
headers['If-None-Match'] = etag unless etag.nil?

Expand Down
14 changes: 14 additions & 0 deletions spec/unleash/bootstrap-resources/features-v1.json
@@ -0,0 +1,14 @@
{
"version": 1,
"features": [
{
"name": "featureX",
"enabled": true,
"strategies": [
{
"name": "default"
}
]
}
]
}
24 changes: 24 additions & 0 deletions spec/unleash/bootstrap/from_file_spec.rb
@@ -0,0 +1,24 @@
require 'spec_helper'
require 'rspec/json_expectations'
require 'unleash/bootstrap/base'
require 'unleash/bootstrap/from_file'
require 'json'

RSpec.describe Unleash::Bootstrap::FromFile do
before do
Unleash.configuration = Unleash::Configuration.new
Unleash.logger = Unleash.configuration.logger
end

it 'loads bootstrap toggle correctly from file' do
bootstrap_file = './spec/unleash/bootstrap-resources/features-v1.json'

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(bootstrap_features).to include_json(file_features)
end
end