unleash 0.1.5 → 0.1.6

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d8661e45cf753ab1c4ffdda0c4445657237cf0dafb26c1f701e258a3fe8c28f
4
- data.tar.gz: 440ddb7d149983453cbda346264e12510fb97a3372ac99bcb5e5e8d60e25df7d
3
+ metadata.gz: 99d6b4f0efb9cf53a2f86930e2e26594d2de8c72aee68ee20e1374f6ac1826cc
4
+ data.tar.gz: ac9905dee38bb80b0f94595907e74856be1842149bfbfc54ee0ff8390bd9dcda
5
5
  SHA512:
6
- metadata.gz: d913c18a4dc0894b19a6281dfb6112891bcb296725afd75aa84172fc7849b36c87962f55151094820d0951c0ea5ec2dd4c8e0e9ce946cc99e2747c9b41e94472
7
- data.tar.gz: bcd865ead7231ea7dbc24b3318498ccbddaab5a7032375c8d25f603952b094141126ebae8e0cf668e74d7ba9e6c73be27f083175211529714967e941d0744521
6
+ metadata.gz: b0ce261b43d7325d3aac1ed62a60d7fdec14543d92e5da63f3e1e699ab6a79d5a529c9080f51dfaf641ce72e18518a61ac1f14395987b3e515503865ed1ef5bf
7
+ data.tar.gz: 3b4cf6548b3c19be261f3794370a80868f0c07cec0988423aa1388f5a75349f880ef1b824652a2bd582814149c985f82e7e1b01772470e41fd119406272f3b42
@@ -9,3 +9,8 @@ Style/RedundantSelf:
9
9
  Enabled: false
10
10
  Style/PreferredHashMethods:
11
11
  Enabled: false
12
+ Style/StringLiterals:
13
+ Enabled: false
14
+
15
+ Layout/SpaceBeforeBlockBraces:
16
+ Enabled: false
@@ -7,6 +7,6 @@ rvm:
7
7
  - 2.6
8
8
  - jruby
9
9
  before_install:
10
- - gem install bundler -v 2.0.1
10
+ - gem install bundler -v 2.0.2
11
11
  # install client spec from official repository:
12
12
  - git clone --depth 5 https://github.com/Unleash/client-specification.git client-specification
data/README.md CHANGED
@@ -2,17 +2,26 @@
2
2
 
3
3
  [![Build Status](https://travis-ci.org/Unleash/unleash-client-ruby.svg?branch=master)](https://travis-ci.org/Unleash/unleash-client-ruby)
4
4
  [![Coverage Status](https://coveralls.io/repos/github/Unleash/unleash-client-ruby/badge.svg?branch=master)](https://coveralls.io/github/Unleash/unleash-client-ruby?branch=master)
5
+ [![Gem Version](https://badge.fury.io/rb/unleash.svg)](https://badge.fury.io/rb/unleash)
5
6
 
6
7
  Unleash client so you can roll out your features with confidence.
7
8
 
8
9
  Leverage the [Unleash Server](https://github.com/Unleash/unleash) for powerful feature toggling in your ruby/rails applications.
9
10
 
11
+ ## Supported Ruby Interpreters
12
+
13
+ * MRI 2.3
14
+ * MRI 2.4
15
+ * MRI 2.5
16
+ * MRI 2.6
17
+ * jruby
18
+
10
19
  ## Installation
11
20
 
12
21
  Add this line to your application's Gemfile:
13
22
 
14
23
  ```ruby
15
- gem 'unleash', '~> 0.1.5'
24
+ gem 'unleash', '~> 0.1.6'
16
25
  ```
17
26
 
18
27
  And then execute:
@@ -50,16 +59,17 @@ Argument | Description | Required? | Type | Default Value|
50
59
  `url` | Unleash server URL. | Y | String | N/A |
51
60
  `app_name` | Name of your program. | Y | String | N/A |
52
61
  `instance_id` | Identifier for the running instance of program. Important so you can trace back to where metrics are being collected from. **Highly recommended be be set.** | N | String | random UUID |
62
+ `environment` | Environment the program is running on. Could be for example `prod` or `dev`. Not yet in use. | N | String | `default` |
53
63
  `refresh_interval` | How often the unleash client should check with the server for configuration changes. | N | Integer | 15 |
54
64
  `metrics_interval` | How often the unleash client should send metrics to server. | N | Integer | 10 |
55
- `disable_client` | Disables all communication with the Unleash server. If set, `is_enabled?` will always answer with the `default_value` and configuration validation is skipped. Defeats the entire purpose of using unleash, but can be useful in when running tests. | N | Boolean | F |
56
- `disable_metrics` | Disables sending metrics to Unleash server. | N | Boolean | F |
65
+ `disable_client` | Disables all communication with the Unleash server, effectively taking it *offline*. If set, `is_enabled?` will always answer with the `default_value` and configuration validation is skipped. Defeats the entire purpose of using unleash, but can be useful in when running tests. | N | Boolean | `false` |
66
+ `disable_metrics` | Disables sending metrics to Unleash server. | N | Boolean | `false` |
57
67
  `custom_http_headers` | Custom headers to send to Unleash. | N | Hash | {} |
58
68
  `timeout` | How long to wait for the connection to be established or wait in reading state (open_timeout/read_timeout) | N | Integer | 30 |
59
69
  `retry_limit` | How many consecutive failures in connecting to the Unleash server are allowed before giving up. | N | Integer | 1 |
60
- `backup_file` | Filename to store the last known state from the Unleash server. Best to not change this from the default. | N | `Dir.tmpdir + "/unleash-#{app_name}-repo.json` |
61
- `logger` | Specify a custom `Logger` class to handle logs from the client. | N | `Logger.new(STDOUT)` |
62
- `log_level` | Change the log level for the `Logger` class. | N | `Logger::ERROR` |
70
+ `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` |
71
+ `logger` | Specify a custom `Logger` class to handle logs for the Unleash client. | N | Class | `Logger.new(STDOUT)` |
72
+ `log_level` | Change the log level for the `Logger` class. Constant from `Logger::Severity`. | N | Constant | `Logger::ERROR` |
63
73
 
64
74
  For in a more in depth look, please see `lib/unleash/configuration.rb`.
65
75
 
@@ -95,6 +105,7 @@ Unleash.configure do |config|
95
105
  config.app_name = Rails.application.class.parent.to_s
96
106
  # config.instance_id = "#{Socket.gethostname}"
97
107
  config.logger = Rails.logger
108
+ config.environment = Rails.env
98
109
  end
99
110
 
100
111
  UNLEASH = Unleash::Client.new
@@ -113,6 +124,7 @@ on_worker_boot do
113
124
  Unleash.configure do |config|
114
125
  config.url = 'http://unleash.herokuapp.com/api'
115
126
  config.app_name = Rails.application.class.parent.to_s
127
+ config.environment = Rails.env
116
128
  end
117
129
  Rails.configuration.unleash = Unleash::Client.new
118
130
  end
@@ -168,9 +180,45 @@ if UNLEASH.is_enabled? "AwesomeFeature", @unleash_context, true
168
180
  end
169
181
  ```
170
182
 
183
+ Alternatively by using `if_enabled` you can send a code block to be executed as a parameter:
184
+
185
+ ```ruby
186
+ UNLEASH.if_enabled "AwesomeFeature", @unleash_context, true do
187
+ puts "AwesomeFeature is enabled by default"
188
+ end
189
+ ```
190
+
191
+ ##### Variations
192
+
193
+ If no variant is found in the server, use the fallback variant.
194
+
195
+ ```ruby
196
+ fallback_variant = Unleash::Variant.new(name: 'default', enabled: true, payload: {"color" => "blue"})
197
+ variant = UNLEASH.get_variant "ColorVariants", @unleash_context, fallback_variant
198
+
199
+ puts "variant color is: #{variant.payload.fetch('color')}"
200
+ ```
201
+
202
+
203
+ #### Client methods
204
+
205
+ Method Name | Description | Return Type |
206
+ ---------|-------------|-------------|
207
+ `is_enabled?` | Check if feature toggle is to be enabled or not. | Boolean |
208
+ `enabled?` | Alias to the `is_enabled?` method. But more ruby idiomatic. | Boolean |
209
+ `if_enabled` | Run a code block, if a feature is enabled. | `yield` |
210
+ `get_variant` | Get variant for a given feature | `Unleash::Variant` |
211
+ `shutdown` | Save metrics to disk, flush metrics to server, and then kill ToggleFetcher and MetricsReporter threads. A safe shutdown. Not really useful in long running applications, like web applications. | nil |
212
+ `shutdown!` | Kill ToggleFetcher and MetricsReporter threads immediately. | nil |
213
+
214
+
171
215
  ## Local test client
172
216
 
173
217
  ```
218
+ # cli unleash client:
219
+ bundle exec bin/unleash-client --help
220
+
221
+ # or a simple sample implementation (with values hardcoded):
174
222
  bundle exec examples/simple.rb
175
223
  ```
176
224
 
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require 'unleash'
5
+ require 'unleash/client'
6
+ require 'unleash/context'
7
+
8
+ options = {
9
+ variant: false,
10
+ verbose: false,
11
+ quiet: false,
12
+ url: 'http://localhost:4242',
13
+ demo: false,
14
+ disable_metrics: true,
15
+ sleep: 0.1,
16
+ }
17
+
18
+ OptionParser.new do |opts|
19
+ opts.banner = "Usage: #{__FILE__} [options] feature [key1=val1] [key2=val2]"
20
+
21
+ opts.on("-V", "--variant", "Fetch variant for feature") do |v|
22
+ options[:variant] = v
23
+ end
24
+
25
+ opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
26
+ options[:verbose] = v
27
+ end
28
+
29
+ opts.on("-q", "--quiet", "Quiet mode, minimum output only") do |v|
30
+ options[:quiet] = v
31
+ end
32
+
33
+ opts.on("-uURL", "--url=URL", "URL base for the Unleash feature toggle service") do |u|
34
+ options[:url] = u
35
+ end
36
+
37
+ opts.on("-d", "--demo", "Demo load by looping, instead of a simple lookup") do |d|
38
+ options[:demo] = d
39
+ end
40
+
41
+ opts.on("-m", "--[no-]metrics", "Enable metrics reporting") do |m|
42
+ options[:disable_metrics] = !m
43
+ end
44
+
45
+ opts.on("-sSLEEP", "--sleep=SLEEP", Float, "Sleep interval between checks (seconds) in demo") do |s|
46
+ options[:sleep] = s
47
+ end
48
+
49
+ opts.on("-h", "--help", "Prints this help") do
50
+ puts opts
51
+ exit
52
+ end
53
+ end.parse!
54
+
55
+ feature_name = ARGV.shift
56
+ raise 'feature_name is required. see --help for usage.' unless feature_name
57
+
58
+ options[:verbose] = false if options[:quiet]
59
+
60
+ @unleash = Unleash::Client.new(
61
+ url: options[:url],
62
+ app_name: 'unleash-client-ruby-cli',
63
+ disable_metrics: options[:metrics],
64
+ log_level: log_level = options[:quiet] ? Logger::ERROR : (options[:verbose] ? Logger::DEBUG : Logger::WARN),
65
+ )
66
+
67
+ context_params = ARGV.map{ |e| e.split("=") }.map{ |k,v| [k.to_sym, v] }.to_h
68
+ context_properties = context_params.reject{ |k,v| [:user_id, :session_id, :remote_address].include? k }
69
+ context_params.select!{ |k,v| [:user_id, :session_id, :remote_address].include? k }
70
+ context_params.merge!( properties: context_properties ) unless context_properties.nil?
71
+ unleash_context = Unleash::Context.new(context_params)
72
+
73
+ if options[:verbose]
74
+ puts "Running configuration:"
75
+ p options
76
+ puts "feature: #{feature_name}"
77
+ puts "context_args: #{ARGV}"
78
+ puts "context_params: #{context_params}"
79
+ puts "context: #{unleash_context}"
80
+ puts ""
81
+ end
82
+
83
+
84
+
85
+ if options[:demo]
86
+ loop do
87
+ enabled = @unleash.is_enabled?(feature_name, unleash_context)
88
+ print enabled ? '.' : '|'
89
+ sleep options[:sleep]
90
+ end
91
+ else
92
+ if options[:variant]
93
+ variant = @unleash.get_variant(feature_name, unleash_context)
94
+ puts " For feature \'#{feature_name}\' got variant \'#{variant}\'"
95
+ else
96
+ if @unleash.is_enabled?(feature_name, unleash_context)
97
+ puts " \'#{feature_name}\' is enabled according to unleash"
98
+ else
99
+ puts " \'#{feature_name}\' is disabled according to unleash"
100
+ end
101
+ end
102
+ end
103
+
104
+
105
+ @unleash.shutdown
@@ -50,4 +50,10 @@ else
50
50
  puts "> #{feature_name} is not enabled"
51
51
  end
52
52
 
53
+ puts "> shutting down client..."
54
+
55
+ @unleash.shutdown
56
+
53
57
  puts ">> END simple.rb"
58
+
59
+
@@ -1,11 +1,10 @@
1
-
2
-
3
1
  module Unleash
4
2
  class ActivationStrategy
5
3
  attr_accessor :name, :params
6
4
 
7
- def initialize(name, params = {})
5
+ def initialize(name, params)
8
6
  self.name = name
7
+
9
8
  if params.is_a?(Hash)
10
9
  self.params = params
11
10
  else
@@ -1,6 +1,7 @@
1
1
  require 'unleash/configuration'
2
2
  require 'unleash/toggle_fetcher'
3
3
  require 'unleash/metrics_reporter'
4
+ require 'unleash/scheduled_executor'
4
5
  require 'unleash/feature_toggle'
5
6
  require 'logger'
6
7
  require 'time'
@@ -8,22 +9,30 @@ require 'time'
8
9
  module Unleash
9
10
 
10
11
  class Client
12
+ attr_accessor :fetcher_scheduled_executor, :metrics_scheduled_executor
13
+
11
14
  def initialize(*opts)
12
15
  Unleash.configuration ||= Unleash::Configuration.new(*opts)
13
16
  Unleash.configuration.validate!
14
17
 
15
- Unleash.logger = Unleash.configuration.logger
18
+ Unleash.logger = Unleash.configuration.logger.clone
16
19
  Unleash.logger.level = Unleash.configuration.log_level
17
20
 
18
21
  unless Unleash.configuration.disable_client
19
22
  Unleash.toggle_fetcher = Unleash::ToggleFetcher.new
23
+
20
24
  register
21
25
 
26
+ self.fetcher_scheduled_executor = Unleash::ScheduledExecutor.new('ToggleFetcher', Unleash.configuration.refresh_interval)
27
+ self.fetcher_scheduled_executor.run do
28
+ Unleash.toggle_fetcher.fetch
29
+ end
30
+
22
31
  unless Unleash.configuration.disable_metrics
23
32
  Unleash.toggle_metrics = Unleash::Metrics.new
24
33
  Unleash.reporter = Unleash::MetricsReporter.new
25
- scheduledExecutor = Unleash::ScheduledExecutor.new('MetricsReporter', Unleash.configuration.metrics_interval)
26
- scheduledExecutor.run do
34
+ self.metrics_scheduled_executor = Unleash::ScheduledExecutor.new('MetricsReporter', Unleash.configuration.metrics_interval)
35
+ self.metrics_scheduled_executor.run do
27
36
  Unleash.reporter.send
28
37
  end
29
38
  end
@@ -53,6 +62,60 @@ module Unleash
53
62
  return toggle_result
54
63
  end
55
64
 
65
+ # enabled? is a more ruby idiomatic method name than is_enabled?
66
+ alias_method :enabled?, :is_enabled?
67
+
68
+ # execute a code block (passed as a parameter), if is_enabled? is true.
69
+ def if_enabled(feature, context = nil, default_value = false, &blk)
70
+ yield if is_enabled?(feature, context, default_value)
71
+ end
72
+
73
+
74
+ def get_variant(feature, context = nil, fallback_variant = false)
75
+ Unleash.logger.debug "Unleash::Client.get_variant for feature: #{feature} with context #{context}"
76
+
77
+ if Unleash.configuration.disable_client
78
+ Unleash.logger.warn "unleash_client is disabled! Always returning #{default_variant} for feature #{feature}!"
79
+ return fallback_variant || Unleash::FeatureToggle.disabled_variant
80
+ end
81
+
82
+ toggle_as_hash = Unleash.toggles.select{ |toggle| toggle['name'] == feature }.first if Unleash.toggles
83
+
84
+ if toggle_as_hash.nil?
85
+ Unleash.logger.debug "Unleash::Client.is_enabled? feature: #{feature} not found"
86
+ return fallback_variant || Unleash::FeatureToggle.disabled_variant
87
+ end
88
+
89
+ toggle = Unleash::FeatureToggle.new(toggle_as_hash)
90
+ variant = toggle.get_variant(context, fallback_variant)
91
+
92
+ if variant.nil?
93
+ Unleash.logger.debug "Unleash::Client.get_variant variants for feature: #{feature} not found"
94
+ return fallback_variant || Unleash::FeatureToggle.disabled_variant
95
+ end
96
+
97
+ # TODO: Add to README: name, payload, enabled (bool)
98
+
99
+ return variant
100
+ end
101
+
102
+ # safe shutdown: also flush metrics to server and toggles to disk
103
+ def shutdown
104
+ unless Unleash.configuration.disable_client
105
+ Unleash.toggle_fetcher.save!
106
+ Unleash.reporter.send unless Unleash.configuration.disable_metrics
107
+ shutdown!
108
+ end
109
+ end
110
+
111
+ # quick shutdown: just kill running threads
112
+ def shutdown!
113
+ unless Unleash.configuration.disable_client
114
+ self.fetcher_scheduled_executor.exit
115
+ self.metrics_scheduled_executor.exit unless Unleash.configuration.disable_metrics
116
+ end
117
+ end
118
+
56
119
  private
57
120
  def info
58
121
  return {
@@ -3,15 +3,24 @@ require 'tmpdir'
3
3
 
4
4
  module Unleash
5
5
  class Configuration
6
- attr_accessor :url, :app_name, :instance_id,
6
+ attr_accessor :url,
7
+ :app_name,
8
+ :environment,
9
+ :instance_id,
7
10
  :custom_http_headers,
8
11
  :disable_client,
9
- :disable_metrics, :timeout, :retry_limit,
10
- :refresh_interval, :metrics_interval,
11
- :backup_file, :logger, :log_level
12
+ :disable_metrics,
13
+ :timeout,
14
+ :retry_limit,
15
+ :refresh_interval,
16
+ :metrics_interval,
17
+ :backup_file,
18
+ :logger,
19
+ :log_level
12
20
 
13
21
  def initialize(opts = {})
14
22
  self.app_name = opts[:app_name] || nil
23
+ self.environment = opts[:environment] || 'default'
15
24
  self.url = opts[:url] || nil
16
25
  self.instance_id = opts[:instance_id] || SecureRandom.uuid
17
26
 
@@ -51,13 +60,8 @@ module Unleash
51
60
  def validate!
52
61
  return if self.disable_client
53
62
 
54
- if self.app_name.nil? or self.url.nil?
55
- raise ArgumentError, "URL and app_name are required parameters."
56
- end
57
-
58
- if ! self.custom_http_headers.is_a?(Hash)
59
- raise ArgumentError, "custom_http_headers must be a hash."
60
- end
63
+ raise ArgumentError, "URL and app_name are required parameters." if self.app_name.nil? or self.url.nil?
64
+ raise ArgumentError, "custom_http_headers must be a hash." unless self.custom_http_headers.is_a?(Hash)
61
65
  end
62
66
 
63
67
  def refresh_backup_file!
@@ -1,39 +1,23 @@
1
1
  module Unleash
2
2
 
3
3
  class Context
4
- attr_accessor :user_id, :session_id, :remote_address, :properties
4
+ attr_accessor :app_name, :environment, :user_id, :session_id, :remote_address, :properties
5
5
 
6
6
  def initialize(params = {})
7
- params_is_a_hash = params.is_a?(Hash)
8
- self.user_id = fetch(params, 'userId')
9
- self.session_id = fetch(params, 'sessionId')
10
- self.remote_address = fetch(params, 'remoteAddress')
11
- self.properties =
12
- if params_is_a_hash && ( params.fetch(:properties, nil) || params.fetch('properties', nil) ).is_a?(Hash)
13
- fetch(params, 'properties', {})
14
- else
15
- {}
16
- end
17
- end
7
+ raise ArgumentError, "Unleash::Context must be initialized with a hash." unless params.is_a?(Hash)
18
8
 
19
- def to_s
20
- "<Context: user_id=#{self.user_id},session_id=#{self.session_id},remote_address=#{self.remote_address},properties=#{self.properties}>"
21
- end
9
+ self.app_name = params.values_at('appName', :app_name).compact.first || ( !Unleash.configuration.nil? ? Unleash.configuration.app_name : nil )
10
+ self.environment = params.values_at('environment', :environment).compact.first || ( !Unleash.configuration.nil? ? Unleash.configuration.environment : 'default' )
11
+ self.user_id = params.values_at('userId', :user_id).compact.first || ''
12
+ self.session_id = params.values_at('sessionId', :session_id).compact.first || ''
13
+ self.remote_address = params.values_at('remoteAddress', :remote_address).compact.first || ''
22
14
 
23
- private
24
- # Fetch key from hash. Try first with using camelCase, and if not found, try with snake case.
25
- # This way we are are idiomatically compliant with ruby, but still giving priority to the same
26
- # key names as in the other clients.
27
- def fetch(params, camelcase_key, default_ret = '')
28
- return default_ret unless params.is_a?(Hash)
29
- return default_ret unless camelcase_key.is_a?(String) or camelcase_key.is_a?(Symbol)
30
-
31
- params.fetch(camelcase_key, nil) || params.fetch(snake_sym(camelcase_key), nil) || default_ret
15
+ properties = params.values_at('properties', :properties).compact.first
16
+ self.properties = properties.is_a?(Hash) ? properties : {}
32
17
  end
33
18
 
34
- # transform CamelCase to snake_case and make it a sym, if it is a string
35
- def snake_sym(key)
36
- key.is_a?(String) ? key.gsub(/(.)([A-Z])/,'\1_\2').downcase.to_sym : key
19
+ def to_s
20
+ "<Context: user_id=#{self.user_id},session_id=#{self.session_id},remote_address=#{self.remote_address},properties=#{self.properties}>"
37
21
  end
38
22
  end
39
23
  end
@@ -1,8 +1,12 @@
1
1
  require 'unleash/activation_strategy'
2
+ require 'unleash/variant_definition'
3
+ require 'unleash/variant'
4
+ require 'unleash/strategy/util'
5
+ require 'securerandom'
2
6
 
3
7
  module Unleash
4
8
  class FeatureToggle
5
- attr_accessor :name, :enabled, :strategies, :choices, :choices_lock
9
+ attr_accessor :name, :enabled, :strategies, :variant_definitions
6
10
 
7
11
  def initialize(params={})
8
12
  params = {} if params.nil?
@@ -11,32 +15,32 @@ module Unleash
11
15
  self.enabled = params.fetch('enabled', false)
12
16
 
13
17
  self.strategies = params.fetch('strategies', [])
14
- .select{|s| ( s.key?('name') && Unleash::STRATEGIES.key?(s['name'].to_sym) ) }
15
- .map{|s| ActivationStrategy.new(s['name'], s['parameters'])} || []
18
+ .select{ |s| ( s.key?('name') && Unleash::STRATEGIES.key?(s['name'].to_sym) ) }
19
+ .map{ |s| ActivationStrategy.new(s['name'], s['parameters'] || {}) } || []
16
20
 
17
- # Unleash.logger.debug "FeatureToggle params: #{params}"
18
- # Unleash.logger.debug "strategies: #{self.strategies}"
21
+ self.variant_definitions = (params.fetch('variants', []) || [])
22
+ .select{ |v| v.is_a?(Hash) && v.key?('name') }
23
+ .map{ |v|
24
+ VariantDefinition.new(
25
+ v.fetch('name', ''),
26
+ v.fetch('weight', 0),
27
+ v.fetch('payload', nil),
28
+ v.fetch('overrides', [])
29
+ )
30
+ } || []
19
31
  end
20
32
 
21
33
  def to_s
22
- "<FeatureToggle: name=#{self.name},enabled=#{self.enabled},choices=#{self.choices},strategies=#{self.strategies}>"
34
+ "<FeatureToggle: name=#{self.name},enabled=#{self.enabled},strategies=#{self.strategies},variant_definitions=#{self.variant_definitions}>"
23
35
  end
24
36
 
25
37
  def is_enabled?(context, default_result)
26
- if not ['NilClass', 'Unleash::Context'].include? context.class.name
38
+ unless ['NilClass', 'Unleash::Context'].include? context.class.name
27
39
  Unleash.logger.error "Provided context is not of the correct type #{context.class.name}, please use Unleash::Context. Context set to nil."
28
40
  context = nil
29
41
  end
30
42
 
31
- result = self.enabled && ( self.strategies.select{ |s|
32
- strategy = Unleash::STRATEGIES.fetch(s.name.to_sym, :unknown)
33
- r = strategy.is_enabled?(s.params, context)
34
- Unleash.logger.debug "Strategy #{s.name} returned #{r} with context: #{context}" #"for params #{s.params} "
35
- r
36
- }.any? || self.strategies.empty? )
37
- result ||= default_result
38
-
39
- Unleash.logger.debug "FeatureToggle (enabled:#{self.enabled} default_result:#{default_result} and Strategies combined returned #{result})"
43
+ result = am_enabled?(context, default_result)
40
44
 
41
45
  choice = result ? :yes : :no
42
46
  Unleash.toggle_metrics.increment(name, choice) unless Unleash.configuration.disable_metrics
@@ -44,5 +48,83 @@ module Unleash
44
48
  return result
45
49
  end
46
50
 
51
+ def get_variant(context, fallback_variant = disabled_variant)
52
+ unless ['NilClass', 'Unleash::Context'].include? context.class.name
53
+ Unleash.logger.error "Provided context is not of the correct type #{context.class.name}, please use Unleash::Context. Context set to nil."
54
+ context = nil
55
+ end
56
+
57
+ unless ['Unleash::Variant'].include? fallback_variant.class.name
58
+ raise ArgumentError, "Provided fallback_variant is not of the correct type #{fallback_variant.class.name}, please use Unleash::Variant."
59
+ end
60
+
61
+ return disabled_variant unless self.enabled && am_enabled?(context, true)
62
+ return disabled_variant if get_sum_variant_defs_weights <= 0
63
+
64
+ variant = variant_from_override_match(context)
65
+ variant = variant_from_weights(context) if variant.nil?
66
+
67
+ Unleash.toggle_metrics.increment_variant(self.name, variant.name) unless Unleash.configuration.disable_metrics
68
+ return variant
69
+ end
70
+
71
+ private
72
+
73
+ # only check if it is enabled, do not do metrics
74
+ def am_enabled?(context, default_result)
75
+ result =
76
+ if self.enabled
77
+ self.strategies.empty? ||
78
+ self.strategies.select{ |s|
79
+ strategy = Unleash::STRATEGIES.fetch(s.name.to_sym, :unknown)
80
+ r = strategy.is_enabled?(s.params, context)
81
+ Unleash.logger.debug "Strategy #{s.name} returned #{r} with context: #{context}" #"for params #{s.params} "
82
+ r
83
+ }.any?
84
+ else
85
+ default_result
86
+ end
87
+
88
+ Unleash.logger.debug "FeatureToggle (enabled:#{self.enabled} default_result:#{default_result} and Strategies combined returned #{result})"
89
+ return result
90
+ end
91
+
92
+ def disabled_variant
93
+ Unleash::Variant.new(name: 'disabled', enabled: false)
94
+ end
95
+
96
+ def get_sum_variant_defs_weights
97
+ self.variant_definitions.map{ |v| v.weight }.reduce(0, :+)
98
+ end
99
+
100
+ def variant_salt(context)
101
+ return context.user_id unless context.user_id.to_s.empty?
102
+ return context.session_id unless context.session_id.to_s.empty?
103
+ return context.remote_address unless context.remote_address.to_s.empty?
104
+ return SecureRandom.random_number
105
+ end
106
+
107
+ def variant_from_override_match(context)
108
+ variant = self.variant_definitions.select{ |vd| vd.override_matches_context?(context) }.first
109
+
110
+ return nil if variant.nil?
111
+ Unleash::Variant.new(name: variant.name, enabled: true, payload: variant.payload)
112
+ end
113
+
114
+ def variant_from_weights(context)
115
+ variant_weight = Unleash::Strategy::Util.get_normalized_number(variant_salt(context), self.name, get_sum_variant_defs_weights())
116
+ prev_weights = 0
117
+
118
+ variant_definition = self.variant_definitions
119
+ .select{ |v|
120
+ res = (prev_weights + v.weight >= variant_weight)
121
+ prev_weights += v.weight
122
+ res
123
+ }
124
+ .first
125
+ return disabled_variant if variant_definition.nil?
126
+
127
+ Unleash::Variant.new(name: variant_definition.name, enabled: true, payload: variant_definition.payload)
128
+ end
47
129
  end
48
130
  end
@@ -3,6 +3,8 @@ module Unleash
3
3
  class Metrics
4
4
  attr_accessor :features
5
5
 
6
+ # NOTE: no mutexes for features
7
+
6
8
  def initialize
7
9
  self.features = {}
8
10
  end
@@ -18,8 +20,15 @@ module Unleash
18
20
  self.features[feature][choice] += 1
19
21
  end
20
22
 
23
+ def increment_variant(feature, variant)
24
+ self.features[feature] = {yes: 0, no: 0} unless self.features.include? feature
25
+ self.features[feature]['variant'] = {} unless self.features[feature].include? 'variant'
26
+ self.features[feature]['variant'][variant] = 0 unless self.features[feature]['variant'].include? variant
27
+ self.features[feature]['variant'][variant] += 1
28
+ end
29
+
21
30
  def reset
22
31
  self.features = {}
23
32
  end
24
33
  end
25
- end
34
+ end
@@ -7,15 +7,12 @@ require 'time'
7
7
  module Unleash
8
8
 
9
9
  class MetricsReporter
10
- attr_accessor :last_time, :client
10
+ attr_accessor :last_time
11
11
 
12
12
  def initialize
13
13
  self.last_time = Time.now
14
14
  end
15
15
 
16
- def build_hash
17
- end
18
-
19
16
  def generate_report
20
17
  now = Time.now
21
18
  start, stop, self.last_time = self.last_time, now, now
@@ -1,17 +1,18 @@
1
1
  module Unleash
2
2
 
3
3
  class ScheduledExecutor
4
- attr_accessor :name, :interval, :max_exceptions, :retry_count
4
+ attr_accessor :name, :interval, :max_exceptions, :retry_count, :thread
5
5
 
6
6
  def initialize(name, interval, max_exceptions = 5)
7
7
  self.name = name || ''
8
8
  self.interval = interval
9
9
  self.max_exceptions = max_exceptions
10
10
  self.retry_count = 0
11
+ self.thread = nil
11
12
  end
12
13
 
13
14
  def run(&blk)
14
- thread = Thread.new do
15
+ self.thread = Thread.new do
15
16
  Thread.current[:name] = self.name
16
17
 
17
18
  loop do
@@ -29,11 +30,23 @@ module Unleash
29
30
  end
30
31
 
31
32
  if self.retry_count > self.max_exceptions
32
- Unleash.logger.info "thread #{name} retry_count (#{self.retry_count}) exceeded max_exceptions (#{self.max_exceptions}). Stopping with retries."
33
+ Unleash.logger.error "thread #{name} retry_count (#{self.retry_count}) exceeded max_exceptions (#{self.max_exceptions}). Stopping with retries."
33
34
  break
34
35
  end
35
36
  end
36
- Unleash.logger.info "thread #{name} ended"
37
+ Unleash.logger.warn "thread #{name} loop ended"
38
+ end
39
+ end
40
+
41
+ def running?
42
+ self.thread.is_a?(Thread) && self.thread.alive?
43
+ end
44
+
45
+ def exit
46
+ if self.running?
47
+ Unleash.logger.warn "thread #{name} will exit!"
48
+ self.thread.exit
49
+ self.thread.join if self.running?
37
50
  end
38
51
  end
39
52
  end
@@ -18,7 +18,7 @@ module Unleash
18
18
  def is_enabled?(params = {}, _context = nil)
19
19
  return false unless params.is_a?(Hash) && params.has_key?(PARAM)
20
20
 
21
- params[PARAM].split(",").map(&:strip).map{|h| h.downcase }.include?(self.hostname)
21
+ params[PARAM].split(",").map(&:strip).map{ |h| h.downcase }.include?(self.hostname)
22
22
  end
23
23
  end
24
24
  end
@@ -7,10 +7,10 @@ module Unleash
7
7
 
8
8
  NORMALIZER = 100
9
9
 
10
- # convert the two strings () into a number between 1 and 100
11
- def get_normalized_number(identifier, group_id)
12
- MurmurHash3::V32.str_hash("#{group_id}:#{identifier}") % NORMALIZER + 1
10
+ # convert the two strings () into a number between 1 and base (100 by default)
11
+ def get_normalized_number(identifier, group_id, base = NORMALIZER)
12
+ MurmurHash3::V32.str_hash("#{group_id}:#{identifier}") % base + 1
13
13
  end
14
14
  end
15
15
  end
16
- end
16
+ end
@@ -1,5 +1,4 @@
1
1
  require 'unleash/configuration'
2
- require 'unleash/scheduled_executor'
3
2
  require 'net/http'
4
3
  require 'json'
5
4
  require 'thread'
@@ -21,12 +20,11 @@ module Unleash
21
20
  fetch
22
21
  rescue Exception => e
23
22
  Unleash.logger.warn "ToggleFetcher was unable to fetch from the network, attempting to read from backup file."
23
+ Unleash.logger.debug "Exception Caught: #{e}"
24
24
  read!
25
25
  end
26
26
 
27
- # once we have initialized, start the fetcher loop
28
- scheduledExecutor = Unleash::ScheduledExecutor.new('ToggleFetcher', Unleash.configuration.refresh_interval)
29
- scheduledExecutor.run { remote_toggles = fetch() }
27
+ # once initialized, somewhere else you will want to start a loop with fetch()
30
28
  end
31
29
 
32
30
  def toggles
@@ -38,7 +36,7 @@ module Unleash
38
36
  end
39
37
 
40
38
  # rename to refresh_from_server! ??
41
- # TODO: should simplify by moving uri / http initialization to class initialization
39
+ # TODO: should simplify by moving uri / http initialization elsewhere
42
40
  def fetch
43
41
  Unleash.logger.debug "fetch()"
44
42
  Unleash.logger.debug "ETag: #{self.etag}" unless self.etag.nil?
@@ -82,6 +80,26 @@ module Unleash
82
80
  save!
83
81
  end
84
82
 
83
+ def save!
84
+ begin
85
+ backup_file = Unleash.configuration.backup_file
86
+ backup_file_tmp = "#{backup_file}.tmp"
87
+
88
+ self.toggle_lock.synchronize do
89
+ file = File.open(backup_file_tmp, "w")
90
+ file.write(self.toggle_cache.to_json)
91
+ File.rename(backup_file_tmp, backup_file)
92
+ end
93
+ rescue Exception => e
94
+ # This is not really the end of the world. Swallowing the exception.
95
+ Unleash.logger.error "Unable to save backup file. Exception thrown #{e.class}:'#{e}'"
96
+ Unleash.logger.error "stacktrace: #{e.backtrace}"
97
+ ensure
98
+ file.close if defined?(file) && ! file.nil?
99
+ self.toggle_lock.unlock if self.toggle_lock.locked?
100
+ end
101
+ end
102
+
85
103
  private
86
104
 
87
105
  def synchronize_with_local_cache!(features)
@@ -102,48 +120,23 @@ module Unleash
102
120
  end
103
121
  end
104
122
 
105
- def backup_file_exists?
106
- File.exists?(backup_file)
107
- end
108
-
109
- def save!
110
- begin
111
- file = File.open(Unleash.configuration.backup_file, "w")
112
-
113
- self.toggle_lock.synchronize do
114
- file.write(self.toggle_cache.to_json)
115
- end
116
- rescue Exception => e
117
- # This is not really the end of the world. Swallowing the exception.
118
- Unleash.logger.error "Unable to save backup file. Exception thrown #{e.class}:'#{e}'"
119
- Unleash.logger.error "stacktrace: #{e.backtrace}"
120
- ensure
121
- file.close unless file.nil?
122
- end
123
- end
124
-
125
123
  def read!
126
124
  Unleash.logger.debug "read!()"
127
- return nil unless File.exists?(Unleash.configuration.backup_file)
125
+ return nil unless File.exist?(Unleash.configuration.backup_file)
128
126
 
129
127
  begin
130
- file = File.open(Unleash.configuration.backup_file, "r")
131
- line_cache = ""
132
- file.each_line do |line|
133
- line_cache += line
134
- end
128
+ file = File.new(Unleash.configuration.backup_file, "r")
129
+ file_content = file.read
135
130
 
136
- backup_as_hash = JSON.parse(line_cache)
131
+ backup_as_hash = JSON.parse(file_content)
137
132
  synchronize_with_local_cache!(backup_as_hash)
138
133
  update_client!
139
-
140
134
  rescue IOError => e
141
135
  Unleash.logger.error "Unable to read the backup_file."
142
136
  rescue JSON::ParserError => e
143
137
  Unleash.logger.error "Unable to parse JSON from existing backup_file."
144
138
  rescue Exception => e
145
- Unleash.logger.error "Unable to extract valid data from backup_file. Exception thrown #{e.class}:'#{e}'"
146
- Unleash.logger.error "stacktrace: #{e.backtrace}"
139
+ Unleash.logger.error "Unable to extract valid data from backup_file. Exception thrown", e
147
140
  ensure
148
141
  file.close unless file.nil?
149
142
  end
@@ -0,0 +1,26 @@
1
+
2
+
3
+ module Unleash
4
+ class Variant
5
+ attr_accessor :name, :enabled, :payload
6
+
7
+ def initialize(params = {})
8
+ raise ArgumentError, "Variant initializer requires a hash." unless params.is_a?(Hash)
9
+
10
+ self.name = params.values_at('name', :name).compact.first
11
+ self.enabled = params.values_at('enabled', :enabled).compact.first || false
12
+ self.payload = params.values_at('payload', :payload).compact.first
13
+
14
+ raise ArgumentError, "Variant requires a name." if self.name.nil?
15
+ end
16
+
17
+ def to_s
18
+ "<Variant: name=#{self.name},enabled=#{self.enabled},payload=#{self.payload}>"
19
+ end
20
+
21
+ def ==(v)
22
+ self.name == v.name && self.enabled == v.enabled && self.payload == v.payload
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ require 'unleash/variant_override'
2
+
3
+
4
+ module Unleash
5
+ class VariantDefinition
6
+ attr_accessor :name, :weight, :payload, :overrides
7
+
8
+ def initialize(name, weight = 0, payload = nil, overrides = [])
9
+ self.name = name
10
+ self.weight = weight
11
+ self.payload = payload
12
+ # self.overrides = overrides
13
+ self.overrides = (overrides || [])
14
+ .select{ |v| v.is_a?(Hash) && v.key?('contextName') }
15
+ .map{ |v| VariantOverride.new(v.fetch('contextName', ''), v.fetch('values', [])) } || []
16
+ end
17
+
18
+ def override_matches_context?(context)
19
+ self.overrides.select{ |o| o.matches_context?(context) }.first
20
+ end
21
+
22
+ def to_s
23
+ "<VariantDefinition: name=#{self.name},weight=#{self.weight},payload=#{self.payload},overrides=#{self.overrides}>"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,44 @@
1
+ module Unleash
2
+ class VariantOverride
3
+ attr_accessor :context_name, :values
4
+
5
+ def initialize(context_name, values = [])
6
+ self.context_name = context_name
7
+ self.values = values || []
8
+
9
+ validate
10
+ end
11
+
12
+ def to_s
13
+ "<VariantOverride: context_name=#{self.context_name},values=#{self.values}>"
14
+ end
15
+
16
+ def matches_context?(context)
17
+ raise ArgumentError, 'context must be of class Unleash::Context' unless context.class.name == 'Unleash::Context'
18
+
19
+ context_value =
20
+ case self.context_name
21
+ when 'userId'
22
+ context.user_id
23
+ when 'sessionId'
24
+ context.session_id
25
+ when 'remoteAddress'
26
+ context.remote_address
27
+ else
28
+ context.properties.fetch(self.context_name, nil)
29
+ end
30
+
31
+ Unleash.logger.debug "VariantOverride: context_name: #{context_name} context_value: #{context_value}"
32
+
33
+ self.values.include? context_value.to_s
34
+ end
35
+
36
+ private
37
+
38
+ def validate
39
+ raise ArgumentError, 'context_name must be a String' unless self.context_name.is_a?(String)
40
+ raise ArgumentError, 'values must be an Array of strings' unless self.values.is_a?(Array) \
41
+ && self.values.reject{ |v| v.is_a?(String) }.empty?
42
+ end
43
+ end
44
+ end
@@ -1,3 +1,3 @@
1
1
  module Unleash
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.6"
3
3
  end
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
29
29
  spec.add_development_dependency "bundler", "~> 2.0"
30
30
  spec.add_development_dependency "rake", "~> 10.0"
31
31
  spec.add_development_dependency "rspec", "~> 3.0"
32
+ spec.add_development_dependency "rspec-json_expectations", "~> 2.1"
32
33
  spec.add_development_dependency "webmock", "~> 3.0"
33
34
  spec.add_development_dependency "coveralls"
34
35
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: unleash
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Renato Arruda
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-01-04 00:00:00.000000000 Z
11
+ date: 2019-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: murmurhash3
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-json_expectations
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.1'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.1'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: webmock
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -114,6 +128,7 @@ files:
114
128
  - TODO.md
115
129
  - bin/console
116
130
  - bin/setup
131
+ - bin/unleash-client
117
132
  - examples/simple.rb
118
133
  - lib/unleash.rb
119
134
  - lib/unleash/activation_strategy.rb
@@ -134,6 +149,9 @@ files:
134
149
  - lib/unleash/strategy/user_with_id.rb
135
150
  - lib/unleash/strategy/util.rb
136
151
  - lib/unleash/toggle_fetcher.rb
152
+ - lib/unleash/variant.rb
153
+ - lib/unleash/variant_definition.rb
154
+ - lib/unleash/variant_override.rb
137
155
  - lib/unleash/version.rb
138
156
  - unleash-client.gemspec
139
157
  homepage: https://github.com/unleash/unleash-client-ruby
@@ -155,8 +173,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
155
173
  - !ruby/object:Gem::Version
156
174
  version: '0'
157
175
  requirements: []
158
- rubyforge_project:
159
- rubygems_version: 2.7.6
176
+ rubygems_version: 3.0.3
160
177
  signing_key:
161
178
  specification_version: 4
162
179
  summary: Unleash feature toggle client.