unleash 4.2.1 → 4.4.0

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: c185b4a8d20c33f221a482a9f2b58fbf028c019513a89b528a491f738ec1308b
4
- data.tar.gz: 8283a887720d6c8f9a72719a8ff4ff800fd88f3941d5f7d5cea83e4ea079a414
3
+ metadata.gz: 5788566712ad3909e6707041673d359a4d9835b59c79bf0e0fac80c7c8a164ed
4
+ data.tar.gz: 707f6875e73a10ef24fdce900aa7c179b0762eedda07ce83a41ea5ed76ba494f
5
5
  SHA512:
6
- metadata.gz: 614df5b819537826bc575a1b6b061ca17561d2108c12e9b32581e458f37ca24beccc83046b1b59c9be0d2f2904087b92f6410f3d31bfa57b176228611a55d96e
7
- data.tar.gz: 35528c4f4f5d39db50c13c7266ac620dec6cdb21154b6f0736dea2946e22b81270737d9085144894d7c1b96c858364ce44f5f948ba24f731e2f033ee0677d67c
6
+ metadata.gz: 5d836f1c130e5db08fdac81f75cb938e93c060f997946ec80bd5819ad098a9b6d7a6d5628f88cd70144899bbe72ac1f5a1e802b4973b6ee0d24eda85e783fab3
7
+ data.tar.gz: 69280e8af54ba2613ef3ae17206f0dfbd31a958d2595162dab71dd0d4cf5815d4dc3e023160c1edc1dbcaeea77d2cfd5f2e468af28051fe41b51dd77fae0f689
@@ -0,0 +1,14 @@
1
+ name: Add new item to project board
2
+
3
+ on:
4
+ issues:
5
+ types:
6
+ - opened
7
+ pull_request_target:
8
+ types:
9
+ - opened
10
+
11
+ jobs:
12
+ add-to-project:
13
+ uses: unleash/.github/.github/workflows/add-item-to-project.yml@main
14
+ secrets: inherit
@@ -5,8 +5,21 @@ on:
5
5
  pull_request:
6
6
 
7
7
  jobs:
8
- test:
8
+ lint:
9
+ name: RuboCop
10
+ timeout-minutes: 30
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v2
14
+ - name: Set up Ruby
15
+ uses: ruby/setup-ruby@v1
16
+ with:
17
+ ruby-version: "2.7"
18
+ bundler-cache: true
19
+ - name: Run RuboCop
20
+ run: bundle exec rubocop
9
21
 
22
+ test:
10
23
  runs-on: ${{ matrix.os }}-latest
11
24
 
12
25
  strategy:
@@ -18,7 +31,7 @@ jobs:
18
31
  - jruby-9.2
19
32
  - jruby-9.3
20
33
  - 3.1
21
- - 3.0
34
+ - '3.0'
22
35
  - 2.7
23
36
  - 2.6
24
37
  - 2.5
@@ -33,14 +46,7 @@ jobs:
33
46
  - name: Install dependencies
34
47
  run: bundle install
35
48
  - name: Download test cases
36
- run: git clone --depth 5 --branch v4.1.0 https://github.com/Unleash/client-specification.git client-specification
37
- - name: rubocop
38
- uses: reviewdog/action-rubocop@v2
39
- with:
40
- github_token: ${{ secrets.GITHUB_TOKEN }}
41
- rubocop_version: gemfile
42
- rubocop_extensions: rubocop-rspec:gemfile
43
- reporter: github-pr-review # Default is github-pr-check
49
+ run: git clone --depth 5 --branch v4.2.2 https://github.com/Unleash/client-specification.git client-specification
44
50
  - name: Run tests
45
51
  run: bundle exec rake
46
52
  env:
data/.gitignore CHANGED
@@ -1,6 +1,5 @@
1
1
  /.bundle/
2
2
  /.yardoc
3
- /Gemfile.lock
4
3
  /_yardoc/
5
4
  /coverage/
6
5
  /doc/
@@ -14,6 +13,7 @@
14
13
 
15
14
  # rspec failure tracking
16
15
  .rspec_status
16
+ Gemfile.lock
17
17
 
18
18
  # Clone of the client-specification
19
19
  /client-specification/
data/.rspec CHANGED
@@ -1,2 +1,3 @@
1
1
  --format documentation
2
2
  --color
3
+ --require 'spec_helper'
data/.rubocop.yml CHANGED
@@ -9,7 +9,7 @@ Naming/PredicateName:
9
9
 
10
10
 
11
11
  Metrics/ClassLength:
12
- Max: 125
12
+ Max: 130
13
13
  Layout/LineLength:
14
14
  Max: 140
15
15
  Metrics/MethodLength:
@@ -34,6 +34,9 @@ Style/StringLiterals:
34
34
  Style/RedundantSelf:
35
35
  Enabled: false
36
36
 
37
+ Style/OptionalBooleanParameter:
38
+ Enabled: false
39
+
37
40
  Style/SymbolArray:
38
41
  EnforcedStyle: brackets
39
42
  Style/WordArray:
data/README.md CHANGED
@@ -41,7 +41,7 @@ It is **required** to configure:
41
41
  - `app_name` with the name of the runninng application.
42
42
  - `custom_http_headers` with `{'Authorization': '<API token>'}` when using Unleash v4.0.0 and later.
43
43
 
44
- Please substitute the example `'http://unleash.herokuapp.com/api'` for the url of your own instance.
44
+ Please substitute the example `'https://unleash.herokuapp.com/api'` for the url of your own instance.
45
45
 
46
46
  It is **highly recommended** to configure:
47
47
  - `instance_id` parameter with a unique identifier for the running instance.
@@ -50,7 +50,7 @@ It is **highly recommended** to configure:
50
50
  ```ruby
51
51
  Unleash.configure do |config|
52
52
  config.app_name = 'my_ruby_app'
53
- config.url = 'http://unleash.herokuapp.com/api'
53
+ config.url = 'https://unleash.herokuapp.com/api'
54
54
  config.custom_http_headers = {'Authorization': '<API token>'}
55
55
  end
56
56
  ```
@@ -58,7 +58,23 @@ end
58
58
  or instantiate the client with the valid configuration:
59
59
 
60
60
  ```ruby
61
- UNLEASH = Unleash::Client.new(url: 'http://unleash.herokuapp.com/api', app_name: 'my_ruby_app', custom_http_headers: {'Authorization': '<API token>'})
61
+ UNLEASH = Unleash::Client.new(url: 'https://unleash.herokuapp.com/api', app_name: 'my_ruby_app', custom_http_headers: {'Authorization': '<API token>'})
62
+ ```
63
+
64
+ ## Dynamic custom HTTP headers
65
+ If you need custom HTTP headers that change during the lifetime of the client, the `custom_http_headers` can be given as a `Proc`.
66
+
67
+ ```ruby
68
+ Unleash.configure do |config|
69
+ config.app_name = 'my_ruby_app'
70
+ config.url = 'https://unleash.herokuapp.com/api'
71
+ config.custom_http_headers = proc do
72
+ {
73
+ 'Authorization': '<API token>',
74
+ 'X-Client-Request-Time': Time.now.iso8601
75
+ }
76
+ end
77
+ end
62
78
  ```
63
79
 
64
80
  #### List of Arguments
@@ -74,13 +90,14 @@ Argument | Description | Required? | Type | Default Value|
74
90
  `metrics_interval` | How often the unleash client should send metrics to server. | N | Integer | 60 |
75
91
  `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` |
76
92
  `disable_metrics` | Disables sending metrics to Unleash server. | N | Boolean | `false` |
77
- `custom_http_headers` | Custom headers to send to Unleash. As of Unleash v4.0.0, the `Authorization` header is required. For example: `{'Authorization': '<API token>'}` | N | Hash | {} |
93
+ `custom_http_headers` | Custom headers to send to Unleash. As of Unleash v4.0.0, the `Authorization` header is required. For example: `{'Authorization': '<API token>'}` | N | Hash/Proc | {} |
78
94
  `timeout` | How long to wait for the connection to be established or wait in reading state (open_timeout/read_timeout) | N | Integer | 30 |
79
95
  `retry_limit` | How many consecutive failures in connecting to the Unleash server are allowed before giving up. The default is to retry indefinitely. | N | Float::INFINITY | 5 |
80
96
  `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` |
81
97
  `logger` | Specify a custom `Logger` class to handle logs for the Unleash client. | N | Class | `Logger.new(STDOUT)` |
82
98
  `log_level` | Change the log level for the `Logger` class. Constant from `Logger::Severity`. | N | Constant | `Logger::WARN` |
83
99
  `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` |
100
+ `strategies` | Strategies manager that holds all strategies and allows to add custom strategies | N | Unleash::Strategies | `Unleash::Strategies.new` |
84
101
 
85
102
  For a more in-depth look, please see `lib/unleash/configuration.rb`.
86
103
 
@@ -95,7 +112,7 @@ Environment Variable | Description
95
112
  require 'unleash'
96
113
  require 'unleash/context'
97
114
 
98
- @unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: 'http://unleash.herokuapp.com/api', custom_http_headers: { 'Authorization': '<API token>' })
115
+ @unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: 'https://unleash.herokuapp.com/api', custom_http_headers: { 'Authorization': '<API token>' })
99
116
 
100
117
  feature_name = "AwesomeFeature"
101
118
  unleash_context = Unleash::Context.new
@@ -117,7 +134,7 @@ Put in `config/initializers/unleash.rb`:
117
134
  ```ruby
118
135
  Unleash.configure do |config|
119
136
  config.app_name = Rails.application.class.parent.to_s
120
- config.url = 'http://unleash.herokuapp.com/api'
137
+ config.url = 'https://unleash.herokuapp.com/api'
121
138
  # config.instance_id = "#{Socket.gethostname}"
122
139
  config.logger = Rails.logger
123
140
  config.environment = Rails.env
@@ -156,7 +173,7 @@ Then you may keep the client configuration still in `config/initializers/unleash
156
173
  Unleash.configure do |config|
157
174
  config.app_name = Rails.application.class.parent.to_s
158
175
  config.environment = Rails.env
159
- config.url = 'http://unleash.herokuapp.com/api'
176
+ config.url = 'https://unleash.herokuapp.com/api'
160
177
  config.custom_http_headers = {'Authorization': '<API token>'}
161
178
  end
162
179
  ```
@@ -204,7 +221,7 @@ on_worker_boot do
204
221
  ::UNLEASH = Unleash::Client.new(
205
222
  app_name: 'my_rails_app',
206
223
  environment: 'development',
207
- url: 'http://unleash.herokuapp.com/api',
224
+ url: 'https://unleash.herokuapp.com/api',
208
225
  custom_http_headers: {'Authorization': '<API token>'},
209
226
  )
210
227
  end
@@ -230,7 +247,7 @@ PhusionPassenger.on_event(:starting_worker_process) do |forked|
230
247
  # config.instance_id = "#{Socket.gethostname}"
231
248
  config.logger = Rails.logger
232
249
  config.environment = Rails.env
233
- config.url = 'http://unleash.herokuapp.com/api'
250
+ config.url = 'https://unleash.herokuapp.com/api'
234
251
  config.custom_http_headers = {'Authorization': '<API token>'}
235
252
  end
236
253
 
@@ -365,11 +382,11 @@ Bootstrap configuration allows the client to be initialized with a predefined se
365
382
  Bootstrapping can be configured by providing a bootstrap configuration when initializing the client.
366
383
  ```ruby
367
384
  @unleash = Unleash::Client.new(
368
- url: 'http://unleash.herokuapp.com/api',
385
+ url: 'https://unleash.herokuapp.com/api',
369
386
  app_name: 'my_ruby_app',
370
387
  custom_http_headers: { 'Authorization': '<API token>' },
371
388
  bootstrap_config: Unleash::Bootstrap::Configuration.new({
372
- url: "http://unleash.herokuapp.com/api/client/features",
389
+ url: "https://unleash.herokuapp.com/api/client/features",
373
390
  url_headers: {'Authorization': '<API token>'}
374
391
  })
375
392
  )
@@ -395,7 +412,7 @@ Example usage:
395
412
 
396
413
  First saving the toggles locally:
397
414
  ```shell
398
- curl -H 'Authorization: <API token>' -XGET 'http://unleash.herokuapp.com/api' > ./default-toggles.json
415
+ curl -H 'Authorization: <API token>' -XGET 'https://unleash.herokuapp.com/api' > ./default-toggles.json
399
416
  ```
400
417
 
401
418
  Now using them on start up:
@@ -408,7 +425,7 @@ custom_boostrapper = lambda {
408
425
 
409
426
  @unleash = Unleash::Client.new(
410
427
  app_name: 'my_ruby_app',
411
- url: 'http://unleash.herokuapp.com/api',
428
+ url: 'https://unleash.herokuapp.com/api',
412
429
  custom_http_headers: { 'Authorization': '<API token>' },
413
430
  bootstrap_config: Unleash::Bootstrap::Configuration.new({
414
431
  block: custom_boostrapper
@@ -456,6 +473,27 @@ This client comes with the all the required strategies out of the box:
456
473
  * UnknownStrategy
457
474
  * UserWithIdStrategy
458
475
 
476
+ ## Custom Strategies
477
+
478
+ Client allows to add [custom activation strategies](https://docs.getunleash.io/advanced/custom_activation_strategy) using configuration.
479
+ In order for strategy to work correctly it should support two methods `name` and `is_enabled?`
480
+
481
+ ```ruby
482
+ class MyCustomStrategy
483
+ def name
484
+ 'muCustomStrategy'
485
+ end
486
+
487
+ def is_enabled?(params = {}, context = nil)
488
+ true
489
+ end
490
+ end
491
+
492
+ Unleash.configure do |config|
493
+ config.strategies.add(MyCustomStrategy.new)
494
+ end
495
+ ```
496
+
459
497
  ## Development
460
498
 
461
499
  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.
@@ -7,7 +7,7 @@ require 'unleash/bootstrap/configuration'
7
7
  puts ">> START bootstrap.rb"
8
8
 
9
9
  @unleash = Unleash::Client.new(
10
- url: 'http://unleash.herokuapp.com/api',
10
+ url: 'https://unleash.herokuapp.com/api',
11
11
  custom_http_headers: { 'Authorization': '943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0' },
12
12
  app_name: 'bootstrap-test',
13
13
  instance_id: 'local-test-cli',
data/examples/simple.rb CHANGED
@@ -6,7 +6,7 @@ require 'unleash/context'
6
6
  puts ">> START simple.rb"
7
7
 
8
8
  # Unleash.configure do |config|
9
- # config.url = 'http://unleash.herokuapp.com/api'
9
+ # config.url = 'https://unleash.herokuapp.com/api'
10
10
  # config.custom_http_headers = { 'Authorization': '943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0' }
11
11
  # config.app_name = 'simple-test'
12
12
  # config.refresh_interval = 2
@@ -18,7 +18,7 @@ puts ">> START simple.rb"
18
18
  # or:
19
19
 
20
20
  @unleash = Unleash::Client.new(
21
- url: 'http://unleash.herokuapp.com/api',
21
+ url: 'https://unleash.herokuapp.com/api',
22
22
  custom_http_headers: { 'Authorization': '943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0' },
23
23
  app_name: 'simple-test',
24
24
  instance_id: 'local-test-cli',
@@ -1,9 +1,10 @@
1
1
  module Unleash
2
2
  class ActivationStrategy
3
- attr_accessor :name, :params, :constraints
3
+ attr_accessor :name, :params, :constraints, :disabled
4
4
 
5
5
  def initialize(name, params, constraints = [])
6
6
  self.name = name
7
+ self.disabled = false
7
8
 
8
9
  if params.is_a?(Hash)
9
10
  self.params = params
@@ -18,6 +19,7 @@ module Unleash
18
19
  self.constraints = constraints
19
20
  else
20
21
  Unleash.logger.warn "Invalid constraints provided for ActivationStrategy (contraints: #{constraints})"
22
+ self.disabled = true
21
23
  self.constraints = []
22
24
  end
23
25
  end
@@ -12,7 +12,7 @@ module Unleash
12
12
  attr_accessor :fetcher_scheduled_executor, :metrics_scheduled_executor
13
13
 
14
14
  def initialize(*opts)
15
- Unleash.configuration ||= Unleash::Configuration.new(*opts)
15
+ Unleash.configuration = Unleash::Configuration.new(*opts) unless opts.empty?
16
16
  Unleash.configuration.validate!
17
17
 
18
18
  Unleash.logger = Unleash.configuration.logger.clone
@@ -45,7 +45,7 @@ module Unleash
45
45
  return default_value
46
46
  end
47
47
 
48
- toggle = Unleash::FeatureToggle.new(toggle_as_hash)
48
+ toggle = Unleash::FeatureToggle.new(toggle_as_hash, Unleash&.segment_cache)
49
49
 
50
50
  toggle.is_enabled?(context)
51
51
  end
@@ -110,7 +110,7 @@ module Unleash
110
110
  'appName': Unleash.configuration.app_name,
111
111
  'instanceId': Unleash.configuration.instance_id,
112
112
  'sdkVersion': "unleash-client-ruby:" + Unleash::VERSION,
113
- 'strategies': Unleash::STRATEGIES.keys,
113
+ 'strategies': Unleash.strategies.keys,
114
114
  'started': Time.now.iso8601(Unleash::TIME_RESOLUTION),
115
115
  'interval': Unleash.configuration.metrics_interval_in_millis
116
116
  }
@@ -20,10 +20,11 @@ module Unleash
20
20
  :backup_file,
21
21
  :logger,
22
22
  :log_level,
23
- :bootstrap_config
23
+ :bootstrap_config,
24
+ :strategies
24
25
 
25
26
  def initialize(opts = {})
26
- ensure_valid_opts(opts)
27
+ validate_custom_http_headers!(opts[:custom_http_headers]) if opts.has_key?(:custom_http_headers)
27
28
  set_defaults
28
29
 
29
30
  initialize_default_logger if opts[:logger].nil?
@@ -40,7 +41,8 @@ module Unleash
40
41
  return if self.disable_client
41
42
 
42
43
  raise ArgumentError, "URL and app_name are required parameters." if self.app_name.nil? || self.url.nil?
43
- raise ArgumentError, "custom_http_headers must be a hash." unless self.custom_http_headers.is_a?(Hash)
44
+
45
+ validate_custom_http_headers!(self.custom_http_headers)
44
46
  end
45
47
 
46
48
  def refresh_backup_file!
@@ -50,8 +52,9 @@ module Unleash
50
52
  def http_headers
51
53
  {
52
54
  'UNLEASH-INSTANCEID' => self.instance_id,
53
- 'UNLEASH-APPNAME' => self.app_name
54
- }.merge(custom_http_headers.dup)
55
+ 'UNLEASH-APPNAME' => self.app_name,
56
+ 'Unleash-Client-Spec' => '4.2.2'
57
+ }.merge!(generate_custom_http_headers)
55
58
  end
56
59
 
57
60
  def fetch_toggles_uri
@@ -78,12 +81,6 @@ module Unleash
78
81
 
79
82
  private
80
83
 
81
- def ensure_valid_opts(opts)
82
- unless opts[:custom_http_headers].is_a?(Hash) || opts[:custom_http_headers].nil?
83
- raise ArgumentError, "custom_http_headers must be a hash."
84
- end
85
- end
86
-
87
84
  def set_defaults
88
85
  self.app_name = nil
89
86
  self.environment = 'default'
@@ -99,12 +96,13 @@ module Unleash
99
96
  self.backup_file = nil
100
97
  self.log_level = Logger::WARN
101
98
  self.bootstrap_config = nil
99
+ self.strategies = Unleash::Strategies.new
102
100
 
103
101
  self.custom_http_headers = {}
104
102
  end
105
103
 
106
104
  def initialize_default_logger
107
- self.logger = Logger.new(STDOUT)
105
+ self.logger = Logger.new($stdout)
108
106
 
109
107
  # on default logger, use custom formatter that includes thread_name:
110
108
  self.logger.formatter = proc do |severity, datetime, _progname, msg|
@@ -118,6 +116,18 @@ module Unleash
118
116
  self
119
117
  end
120
118
 
119
+ def validate_custom_http_headers!(custom_http_headers)
120
+ return if custom_http_headers.is_a?(Hash) || custom_http_headers.respond_to?(:call)
121
+
122
+ raise ArgumentError, "custom_http_headers must be a Hash or a Proc."
123
+ end
124
+
125
+ def generate_custom_http_headers
126
+ return self.custom_http_headers.call if self.custom_http_headers.respond_to?(:call)
127
+
128
+ self.custom_http_headers
129
+ end
130
+
121
131
  def set_option(opt, val)
122
132
  __send__("#{opt}=", val)
123
133
  rescue NoMethodError
@@ -1,12 +1,11 @@
1
1
  require 'date'
2
-
3
2
  module Unleash
4
3
  class Constraint
5
4
  attr_accessor :context_name, :operator, :value, :inverted, :case_insensitive
6
5
 
7
6
  OPERATORS = {
8
- IN: ->(context_v, constraint_v){ constraint_v.include? context_v },
9
- NOT_IN: ->(context_v, constraint_v){ !constraint_v.include? context_v },
7
+ IN: ->(context_v, constraint_v){ constraint_v.include? context_v.to_s },
8
+ NOT_IN: ->(context_v, constraint_v){ !constraint_v.include? context_v.to_s },
10
9
  STR_STARTS_WITH: ->(context_v, constraint_v){ constraint_v.any?{ |v| context_v.start_with? v } },
11
10
  STR_ENDS_WITH: ->(context_v, constraint_v){ constraint_v.any?{ |v| context_v.end_with? v } },
12
11
  STR_CONTAINS: ->(context_v, constraint_v){ constraint_v.any?{ |v| context_v.include? v } },
@@ -19,16 +18,21 @@ module Unleash
19
18
  DATE_BEFORE: ->(context_v, constraint_v){ on_valid_date(constraint_v, context_v){ |x, y| (x > y) } },
20
19
  SEMVER_EQ: ->(context_v, constraint_v){ on_valid_version(constraint_v, context_v){ |x, y| (x == y) } },
21
20
  SEMVER_GT: ->(context_v, constraint_v){ on_valid_version(constraint_v, context_v){ |x, y| (x < y) } },
22
- SEMVER_LT: ->(context_v, constraint_v){ on_valid_version(constraint_v, context_v){ |x, y| (x > y) } }
21
+ SEMVER_LT: ->(context_v, constraint_v){ on_valid_version(constraint_v, context_v){ |x, y| (x > y) } },
22
+ FALLBACK_VALIDATOR: ->(_context_v, _constraint_v){ false }
23
23
  }.freeze
24
24
 
25
25
  LIST_OPERATORS = [:IN, :NOT_IN, :STR_STARTS_WITH, :STR_ENDS_WITH, :STR_CONTAINS].freeze
26
26
 
27
27
  def initialize(context_name, operator, value = [], inverted: false, case_insensitive: false)
28
28
  raise ArgumentError, "context_name is not a String" unless context_name.is_a?(String)
29
- raise ArgumentError, "operator does not hold a valid value:" + OPERATORS.keys unless OPERATORS.include? operator.to_sym
30
29
 
31
- self.validate_constraint_value_type(operator.to_sym, value)
30
+ unless OPERATORS.include? operator.to_sym
31
+ Unleash.logger.warn "Operator #{operator} is not a supported operator, " \
32
+ "falling back to FALLBACK_VALIDATOR which skips this constraint."
33
+ operator = "FALLBACK_VALIDATOR"
34
+ end
35
+ self.log_inconsistent_constraint_configuration(operator.to_sym, value)
32
36
 
33
37
  self.context_name = context_name
34
38
  self.operator = operator.to_sym
@@ -38,8 +42,8 @@ module Unleash
38
42
  end
39
43
 
40
44
  def matches_context?(context)
41
- Unleash.logger.debug "Unleash::Constraint matches_context? value: #{self.value} context.get_by_name(#{self.context_name})" \
42
- " #{context.get_by_name(self.context_name)} "
45
+ Unleash.logger.debug "Unleash::Constraint matches_context? value: #{self.value} context.get_by_name(#{self.context_name})"
46
+
43
47
  match = matches_constraint?(context)
44
48
  self.inverted ? !match : match
45
49
  rescue KeyError
@@ -79,19 +83,25 @@ module Unleash
79
83
  end
80
84
 
81
85
  # This should be a private method but for some reason this fails on Ruby 2.5
82
- def validate_constraint_value_type(operator, value)
83
- raise ArgumentError, "context_name is not an Array" if LIST_OPERATORS.include?(operator) && value.is_a?(String)
84
- raise ArgumentError, "context_name is not a String" if !LIST_OPERATORS.include?(operator) && value.is_a?(Array)
86
+ def log_inconsistent_constraint_configuration(operator, value)
87
+ Unleash.logger.warn "value is a String, operator is expecting an Array" if LIST_OPERATORS.include?(operator) && value.is_a?(String)
88
+ Unleash.logger.warn "value is an Array, operator is expecting a String" if !LIST_OPERATORS.include?(operator) && value.is_a?(Array)
85
89
  end
86
90
 
87
91
  private
88
92
 
89
93
  def matches_constraint?(context)
94
+ Unleash.logger.debug "Unleash::Constraint matches_constraint? value: #{self.value} operator: #{self.operator} " \
95
+ " context.get_by_name(#{self.context_name})"
96
+
90
97
  unless OPERATORS.include?(self.operator)
91
98
  Unleash.logger.warn "Invalid constraint operator: #{self.operator}, this should be unreachable. Always returning false."
92
99
  false
93
100
  end
94
101
 
102
+ # when the operator is NOT_IN and there is no data, return true. In all other cases the operator doesn't match.
103
+ return self.operator == :NOT_IN unless context.include?(self.context_name)
104
+
95
105
  v = self.value.dup
96
106
  context_value = context.get_by_name(self.context_name)
97
107
 
@@ -33,6 +33,13 @@ module Unleash
33
33
  end
34
34
  end
35
35
 
36
+ def include?(name)
37
+ normalized_name = underscore(name)
38
+ return self.instance_variable_defined? "@#{normalized_name}" if ATTRS.include? normalized_name.to_sym
39
+
40
+ self.properties.include?(normalized_name.to_sym) || self.properties.include?(name.to_sym)
41
+ end
42
+
36
43
  private
37
44
 
38
45
  # Method to fetch values from hash for two types of keys: string in camelCase and symbol in snake_case
@@ -9,13 +9,13 @@ module Unleash
9
9
  class FeatureToggle
10
10
  attr_accessor :name, :enabled, :strategies, :variant_definitions
11
11
 
12
- def initialize(params = {})
12
+ def initialize(params = {}, segment_map = {})
13
13
  params = {} if params.nil?
14
14
 
15
15
  self.name = params.fetch('name', nil)
16
16
  self.enabled = params.fetch('enabled', false)
17
17
 
18
- self.strategies = initialize_strategies(params)
18
+ self.strategies = initialize_strategies(params, segment_map)
19
19
  self.variant_definitions = initialize_variant_definitions(params)
20
20
  end
21
21
 
@@ -75,12 +75,14 @@ module Unleash
75
75
  end
76
76
 
77
77
  def strategy_enabled?(strategy, context)
78
- r = Unleash::STRATEGIES.fetch(strategy.name.to_sym, :unknown).is_enabled?(strategy.params, context)
78
+ r = Unleash.strategies.fetch(strategy.name).is_enabled?(strategy.params, context)
79
79
  Unleash.logger.debug "Unleash::FeatureToggle.strategy_enabled? Strategy #{strategy.name} returned #{r} with context: #{context}"
80
80
  r
81
81
  end
82
82
 
83
83
  def strategy_constraint_matches?(strategy, context)
84
+ return false if strategy.disabled
85
+
84
86
  strategy.constraints.empty? || strategy.constraints.all?{ |c| c.matches_context?(context) }
85
87
  end
86
88
 
@@ -128,26 +130,35 @@ module Unleash
128
130
  context
129
131
  end
130
132
 
131
- def initialize_strategies(params)
133
+ def initialize_strategies(params, segment_map)
132
134
  params.fetch('strategies', [])
133
- .select{ |s| s.has_key?('name') && Unleash::STRATEGIES.has_key?(s['name'].to_sym) }
135
+ .select{ |s| s.has_key?('name') && Unleash.strategies.includes?(s['name']) }
134
136
  .map do |s|
135
137
  ActivationStrategy.new(
136
138
  s['name'],
137
139
  s['parameters'],
138
- (s['constraints'] || []).map do |c|
139
- Constraint.new(
140
- c.fetch('contextName'),
141
- c.fetch('operator'),
142
- c.fetch('values', nil) || c.fetch('value', nil),
143
- inverted: c.fetch('inverted', false),
144
- case_insensitive: c.fetch('caseInsensitive', false)
145
- )
146
- end
140
+ resolve_constraints(s, segment_map)
147
141
  )
148
142
  end || []
149
143
  end
150
144
 
145
+ def resolve_constraints(strategy, segment_map)
146
+ segment_constraints = (strategy["segments"] || []).map do |segment_id|
147
+ segment_map[segment_id]&.fetch("constraints")
148
+ end
149
+ (strategy.fetch("constraints", []) + segment_constraints).flatten.map do |constraint|
150
+ return nil if constraint.nil?
151
+
152
+ Constraint.new(
153
+ constraint.fetch('contextName'),
154
+ constraint.fetch('operator'),
155
+ constraint.fetch('value', nil) || constraint.fetch('values', nil),
156
+ inverted: constraint.fetch('inverted', false),
157
+ case_insensitive: constraint.fetch('caseInsensitive', false)
158
+ )
159
+ end
160
+ end
161
+
151
162
  def initialize_variant_definitions(params)
152
163
  (params.fetch('variants', []) || [])
153
164
  .select{ |v| v.is_a?(Hash) && v.has_key?('name') }
@@ -0,0 +1,80 @@
1
+ require 'unleash/strategy/base'
2
+ Gem.find_files('unleash/strategy/**/*.rb').each{ |path| require path }
3
+
4
+ module Unleash
5
+ class Strategies
6
+ def initialize
7
+ @strategies = {}
8
+ register_strategies
9
+ end
10
+
11
+ def keys
12
+ @strategies.keys
13
+ end
14
+
15
+ def includes?(name)
16
+ @strategies.has_key?(name.to_s)
17
+ end
18
+
19
+ def fetch(name)
20
+ raise Unleash::Strategy::NotImplemented, "Strategy is not implemented" unless (strategy = @strategies[name.to_s])
21
+
22
+ strategy
23
+ end
24
+
25
+ def add(strategy)
26
+ @strategies[strategy.name] = strategy
27
+ end
28
+
29
+ def []=(key, strategy)
30
+ warn_deprecated_registration(strategy, 'modifying Unleash::STRATEGIES')
31
+ @strategies[key.to_s] = strategy
32
+ end
33
+
34
+ def [](key)
35
+ @strategies[key.to_s]
36
+ end
37
+
38
+ def register_strategies
39
+ register_base_strategies
40
+ register_custom_strategies
41
+ end
42
+
43
+ protected
44
+
45
+ # Deprecated: Use Unleash.configuration to add custom strategies
46
+ def register_custom_strategies
47
+ Unleash::Strategy.constants
48
+ .select{ |c| Unleash::Strategy.const_get(c).is_a? Class }
49
+ .reject{ |c| ['NotImplemented', 'Base'].include?(c.to_s) } # Reject abstract classes
50
+ .map{ |c| Object.const_get("Unleash::Strategy::#{c}") }
51
+ .reject{ |c| DEFAULT_STRATEGIES.include?(c) } # Reject base classes
52
+ .each do |c|
53
+ strategy = c.new
54
+ warn_deprecated_registration(strategy, 'adding custom class into Unleash::Strategy namespace')
55
+ self.add(strategy)
56
+ end
57
+ end
58
+
59
+ def register_base_strategies
60
+ DEFAULT_STRATEGIES.each{ |c| self.add(c.new) }
61
+ end
62
+
63
+ DEFAULT_STRATEGIES = [
64
+ Unleash::Strategy::ApplicationHostname,
65
+ Unleash::Strategy::Default,
66
+ Unleash::Strategy::FlexibleRollout,
67
+ Unleash::Strategy::GradualRolloutRandom,
68
+ Unleash::Strategy::GradualRolloutSessionId,
69
+ Unleash::Strategy::GradualRolloutUserId,
70
+ Unleash::Strategy::RemoteAddress,
71
+ Unleash::Strategy::UserWithId
72
+ ].freeze
73
+
74
+ def warn_deprecated_registration(strategy, method)
75
+ warn "[DEPRECATED] Registering custom Unleash strategy by #{method} is deprecated.
76
+ Please use Unleash configuration to register custom strategy: " \
77
+ "`Unleash.configure {|c| c.strategies.add(#{strategy.class.name}.new) }`"
78
+ end
79
+ end
80
+ end
@@ -10,7 +10,7 @@ module Unleash
10
10
  # need: params['percentage']
11
11
  def is_enabled?(params = {}, context = nil)
12
12
  return false unless params.is_a?(Hash)
13
- return false unless context.class.name == 'Unleash::Context'
13
+ return false unless context.instance_of?(Unleash::Context)
14
14
 
15
15
  stickiness = params.fetch('stickiness', 'default')
16
16
  stickiness_id = resolve_stickiness(stickiness, context)
@@ -10,7 +10,7 @@ module Unleash
10
10
  # need: params['percentage'], params['groupId'], context.user_id,
11
11
  def is_enabled?(params = {}, context = nil)
12
12
  return false unless params.is_a?(Hash) && params.has_key?('percentage')
13
- return false unless context.class.name == 'Unleash::Context'
13
+ return false unless context.instance_of?(Unleash::Context)
14
14
  return false if context.session_id.nil? || context.session_id.empty?
15
15
 
16
16
  percentage = Integer(params['percentage'] || 0)
@@ -10,7 +10,7 @@ module Unleash
10
10
  # need: params['percentage'], params['groupId'], context.user_id,
11
11
  def is_enabled?(params = {}, context = nil, _constraints = [])
12
12
  return false unless params.is_a?(Hash) && params.has_key?('percentage')
13
- return false unless context.class.name == 'Unleash::Context'
13
+ return false unless context.instance_of?(Unleash::Context)
14
14
  return false if context.user_id.nil? || context.user_id.empty?
15
15
 
16
16
  percentage = Integer(params['percentage'] || 0)
@@ -11,7 +11,7 @@ module Unleash
11
11
  def is_enabled?(params = {}, context = nil)
12
12
  return false unless params.is_a?(Hash) && params.has_key?(PARAM)
13
13
  return false unless params.fetch(PARAM, nil).is_a? String
14
- return false unless context.class.name == 'Unleash::Context'
14
+ return false unless context.instance_of?(Unleash::Context)
15
15
 
16
16
  remote_address = ipaddr_or_nil_from_str(context.remote_address)
17
17
 
@@ -11,7 +11,7 @@ module Unleash
11
11
  def is_enabled?(params = {}, context = nil)
12
12
  return false unless params.is_a?(Hash) && params.has_key?(PARAM)
13
13
  return false unless params.fetch(PARAM, nil).is_a? String
14
- return false unless context.class.name == 'Unleash::Context'
14
+ return false unless context.instance_of?(Unleash::Context)
15
15
 
16
16
  params[PARAM].split(",").map(&:strip).include?(context.user_id)
17
17
  end
@@ -5,11 +5,12 @@ require 'json'
5
5
 
6
6
  module Unleash
7
7
  class ToggleFetcher
8
- attr_accessor :toggle_cache, :toggle_lock, :toggle_resource, :etag, :retry_count
8
+ attr_accessor :toggle_cache, :toggle_lock, :toggle_resource, :etag, :retry_count, :segment_cache
9
9
 
10
10
  def initialize
11
11
  self.etag = nil
12
12
  self.toggle_cache = nil
13
+ self.segment_cache = nil
13
14
  self.toggle_lock = Mutex.new
14
15
  self.toggle_resource = ConditionVariable.new
15
16
  self.retry_count = 0
@@ -65,24 +66,20 @@ module Unleash
65
66
 
66
67
  def save!
67
68
  Unleash.logger.debug "Will save toggles to disk now"
68
- begin
69
- backup_file = Unleash.configuration.backup_file
70
- backup_file_tmp = "#{backup_file}.tmp"
71
69
 
72
- self.toggle_lock.synchronize do
73
- file = File.open(backup_file_tmp, "w")
70
+ backup_file = Unleash.configuration.backup_file
71
+ backup_file_tmp = "#{backup_file}.tmp"
72
+
73
+ self.toggle_lock.synchronize do
74
+ File.open(backup_file_tmp, "w") do |file|
74
75
  file.write(self.toggle_cache.to_json)
75
- file.close
76
- File.rename(backup_file_tmp, backup_file)
77
76
  end
78
- rescue StandardError => e
79
- # This is not really the end of the world. Swallowing the exception.
80
- Unleash.logger.error "Unable to save backup file. Exception thrown #{e.class}:'#{e}'"
81
- Unleash.logger.error "stacktrace: #{e.backtrace}"
82
- ensure
83
- file&.close if defined?(file)
84
- self.toggle_lock.unlock if self.toggle_lock.locked?
77
+ File.rename(backup_file_tmp, backup_file)
85
78
  end
79
+ rescue StandardError => e
80
+ # This is not really the end of the world. Swallowing the exception.
81
+ Unleash.logger.error "Unable to save backup file. Exception thrown #{e.class}:'#{e}'"
82
+ Unleash.logger.error "stacktrace: #{e.backtrace}"
86
83
  end
87
84
 
88
85
  private
@@ -99,9 +96,10 @@ module Unleash
99
96
  end
100
97
 
101
98
  def update_running_client!
102
- if Unleash.toggles != self.toggles
99
+ if Unleash.toggles != self.toggles["features"] || Unleash.segment_cache != self.toggles["segments"]
103
100
  Unleash.logger.info "Updating toggles to main client, there has been a change in the server."
104
- Unleash.toggles = self.toggles
101
+ Unleash.toggles = self.toggles["features"]
102
+ Unleash.segment_cache = self.toggles["segments"]
105
103
  end
106
104
  end
107
105
 
@@ -110,22 +108,15 @@ module Unleash
110
108
  backup_file = Unleash.configuration.backup_file
111
109
  return nil unless File.exist?(backup_file)
112
110
 
113
- begin
114
- file = File.new(backup_file, "r")
115
- file_content = file.read
116
-
117
- backup_as_hash = JSON.parse(file_content)
118
- synchronize_with_local_cache!(backup_as_hash)
119
- update_running_client!
120
- rescue IOError => e
121
- Unleash.logger.error "Unable to read the backup_file: #{e}"
122
- rescue JSON::ParserError => e
123
- Unleash.logger.error "Unable to parse JSON from existing backup_file: #{e}"
124
- rescue StandardError => e
125
- Unleash.logger.error "Unable to extract valid data from backup_file. Exception thrown: #{e}"
126
- ensure
127
- file&.close
128
- end
111
+ backup_as_hash = JSON.parse(File.read(backup_file))
112
+ synchronize_with_local_cache!(backup_as_hash)
113
+ update_running_client!
114
+ rescue IOError => e
115
+ Unleash.logger.error "Unable to read the backup_file: #{e}"
116
+ rescue JSON::ParserError => e
117
+ Unleash.logger.error "Unable to parse JSON from existing backup_file: #{e}"
118
+ rescue StandardError => e
119
+ Unleash.logger.error "Unable to extract valid data from backup_file. Exception thrown: #{e}"
129
120
  end
130
121
 
131
122
  def bootstrap
@@ -137,10 +128,19 @@ module Unleash
137
128
  Unleash.configuration.bootstrap_config = nil
138
129
  end
139
130
 
131
+ def build_segment_map(segments_array)
132
+ return {} if segments_array.nil?
133
+
134
+ segments_array.map{ |segment| [segment["id"], segment] }.to_h
135
+ end
136
+
140
137
  # @param response_body [String]
141
138
  def get_features(response_body)
142
139
  response_hash = JSON.parse(response_body)
143
- return response_hash['features'] if response_hash['version'] >= 1
140
+
141
+ if response_hash['version'] >= 1
142
+ return { "features" => response_hash["features"], "segments" => build_segment_map(response_hash["segments"]) }
143
+ end
144
144
 
145
145
  raise NotImplemented, "Version of features provided by unleash server" \
146
146
  " is unsupported by this client."
@@ -4,7 +4,7 @@ module Unleash
4
4
  class VariantDefinition
5
5
  attr_accessor :name, :weight, :payload, :overrides, :stickiness
6
6
 
7
- def initialize(name, weight = 0, payload = nil, stickiness = nil, overrides = [])
7
+ def initialize(name, weight = 0, payload = nil, stickiness = nil, overrides = []) # rubocop:disable Metrics/ParameterLists
8
8
  self.name = name
9
9
  self.weight = weight
10
10
  self.payload = payload
@@ -14,7 +14,7 @@ module Unleash
14
14
  end
15
15
 
16
16
  def matches_context?(context)
17
- raise ArgumentError, 'context must be of class Unleash::Context' unless context.class.name == 'Unleash::Context'
17
+ raise ArgumentError, 'context must be of class Unleash::Context' unless context.instance_of?(Unleash::Context)
18
18
 
19
19
  context_value =
20
20
  case self.context_name
@@ -1,3 +1,3 @@
1
1
  module Unleash
2
- VERSION = "4.2.1".freeze
2
+ VERSION = "4.4.0".freeze
3
3
  end
data/lib/unleash.rb CHANGED
@@ -1,35 +1,31 @@
1
1
  require 'unleash/version'
2
2
  require 'unleash/configuration'
3
- require 'unleash/strategy/base'
3
+ require 'unleash/strategies'
4
4
  require 'unleash/context'
5
5
  require 'unleash/client'
6
6
  require 'logger'
7
7
 
8
- Gem.find_files('unleash/strategy/**/*.rb').each{ |path| require path unless path.end_with? '_spec.rb' }
9
-
10
8
  module Unleash
11
9
  TIME_RESOLUTION = 3
12
10
 
13
- STRATEGIES = Unleash::Strategy.constants
14
- .select{ |c| Unleash::Strategy.const_get(c).is_a? Class }
15
- .reject{ |c| ['NotImplemented', 'Base'].include?(c.to_s) }
16
- .map do |c|
17
- lowered_c = c.to_s
18
- lowered_c[0] = lowered_c[0].downcase
19
- [lowered_c.to_sym, Object.const_get("Unleash::Strategy::#{c}").new]
20
- end
21
- .to_h
22
-
23
11
  class << self
24
- attr_accessor :configuration, :toggle_fetcher, :toggles, :toggle_metrics, :reporter, :logger
12
+ attr_accessor :configuration, :toggle_fetcher, :toggles, :toggle_metrics, :reporter, :segment_cache, :logger
25
13
  end
26
14
 
15
+ self.configuration = Unleash::Configuration.new
16
+
17
+ # Deprecated: Use Unleash.configure to add custom strategies
18
+ STRATEGIES = self.configuration.strategies
19
+
27
20
  # Support for configuration via yield:
28
21
  def self.configure
29
- self.configuration ||= Unleash::Configuration.new
30
22
  yield(configuration)
31
23
 
32
24
  self.configuration.validate!
33
25
  self.configuration.refresh_backup_file!
34
26
  end
27
+
28
+ def self.strategies
29
+ self.configuration.strategies
30
+ end
35
31
  end
@@ -31,7 +31,7 @@ Gem::Specification.new do |spec|
31
31
  spec.add_development_dependency "rspec-json_expectations", "~> 2.2"
32
32
  spec.add_development_dependency "webmock", "~> 3.8"
33
33
 
34
- spec.add_development_dependency "rubocop", "~> 0.80"
34
+ spec.add_development_dependency "rubocop", "~> 1.28.2"
35
35
  spec.add_development_dependency "simplecov", "~> 0.21.2"
36
36
  spec.add_development_dependency "simplecov-lcov", "~> 0.8.0"
37
37
  end
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: 4.2.1
4
+ version: 4.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Renato Arruda
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-03-29 00:00:00.000000000 Z
11
+ date: 2022-09-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: murmurhash3
@@ -100,14 +100,14 @@ dependencies:
100
100
  requirements:
101
101
  - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: '0.80'
103
+ version: 1.28.2
104
104
  type: :development
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: '0.80'
110
+ version: 1.28.2
111
111
  - !ruby/object:Gem::Dependency
112
112
  name: simplecov
113
113
  requirement: !ruby/object:Gem::Requirement
@@ -146,6 +146,7 @@ executables:
146
146
  extensions: []
147
147
  extra_rdoc_files: []
148
148
  files:
149
+ - ".github/workflows/add-to-project.yml"
149
150
  - ".github/workflows/pull_request.yml"
150
151
  - ".gitignore"
151
152
  - ".rspec"
@@ -175,6 +176,7 @@ files:
175
176
  - lib/unleash/metrics.rb
176
177
  - lib/unleash/metrics_reporter.rb
177
178
  - lib/unleash/scheduled_executor.rb
179
+ - lib/unleash/strategies.rb
178
180
  - lib/unleash/strategy/application_hostname.rb
179
181
  - lib/unleash/strategy/base.rb
180
182
  - lib/unleash/strategy/default.rb
@@ -211,7 +213,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
211
213
  - !ruby/object:Gem::Version
212
214
  version: '0'
213
215
  requirements: []
214
- rubygems_version: 3.3.6
216
+ rubygems_version: 3.3.5
215
217
  signing_key:
216
218
  specification_version: 4
217
219
  summary: Unleash feature toggle client.