unleash 4.0.0 → 4.2.1

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: 4ce69a2f4588a0d5e459e76aa8d78894497736f484ae20f5d987d50b34aced50
4
- data.tar.gz: 24222c2c023197dc4f283ee4e09316f233245b2e82ca4c9ad934fe5519de75d5
3
+ metadata.gz: c185b4a8d20c33f221a482a9f2b58fbf028c019513a89b528a491f738ec1308b
4
+ data.tar.gz: 8283a887720d6c8f9a72719a8ff4ff800fd88f3941d5f7d5cea83e4ea079a414
5
5
  SHA512:
6
- metadata.gz: 6e056f24c5f840b211f04bc3c605ccf0846d1ff8717cc824d1e50307e636f28c50f91a8fe85f648de9f16377d5441d5feea459a15ad1eae00c35a6238ebb0355
7
- data.tar.gz: 51b49947d4aea7d1901bf0083ef366604b5d1cd7859cab1cfaceacc420fb056c4188dd4512c1a7feddc5f9711e0c5ad2660e91c1e681cd57e47307f29948790c
6
+ metadata.gz: 614df5b819537826bc575a1b6b061ca17561d2108c12e9b32581e458f37ca24beccc83046b1b59c9be0d2f2904087b92f6410f3d31bfa57b176228611a55d96e
7
+ data.tar.gz: 35528c4f4f5d39db50c13c7266ac620dec6cdb21154b6f0736dea2946e22b81270737d9085144894d7c1b96c858364ce44f5f948ba24f731e2f033ee0677d67c
@@ -15,7 +15,9 @@ jobs:
15
15
  - ubuntu
16
16
  - macos
17
17
  ruby-version:
18
- - jruby
18
+ - jruby-9.2
19
+ - jruby-9.3
20
+ - 3.1
19
21
  - 3.0
20
22
  - 2.7
21
23
  - 2.6
@@ -31,7 +33,7 @@ jobs:
31
33
  - name: Install dependencies
32
34
  run: bundle install
33
35
  - name: Download test cases
34
- run: git clone --depth 5 --branch v4.0.0 https://github.com/Unleash/client-specification.git client-specification
36
+ run: git clone --depth 5 --branch v4.1.0 https://github.com/Unleash/client-specification.git client-specification
35
37
  - name: rubocop
36
38
  uses: reviewdog/action-rubocop@v2
37
39
  with:
data/.rubocop.yml CHANGED
@@ -7,8 +7,9 @@ Naming/PredicateName:
7
7
  AllowedMethods:
8
8
  - is_enabled?
9
9
 
10
+
10
11
  Metrics/ClassLength:
11
- Max: 120
12
+ Max: 125
12
13
  Layout/LineLength:
13
14
  Max: 140
14
15
  Metrics/MethodLength:
@@ -16,13 +17,10 @@ Metrics/MethodLength:
16
17
  Metrics/BlockLength:
17
18
  Max: 110
18
19
  Exclude:
19
- - 'spec/unleash/configuration_spec.rb'
20
- - 'spec/unleash/client_spec.rb'
21
- - 'spec/unleash/context_spec.rb'
22
- - 'spec/unleash/feature_toggle_spec.rb'
20
+ - 'spec/**/*.rb'
23
21
 
24
22
  Metrics/AbcSize:
25
- Max: 28
23
+ Max: 30
26
24
  Metrics/CyclomaticComplexity:
27
25
  Max: 9
28
26
  Metrics/PerceivedComplexity:
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Unleash::Client
2
2
 
3
- [![Build Status](https://travis-ci.org/Unleash/unleash-client-ruby.svg?branch=master)](https://travis-ci.org/Unleash/unleash-client-ruby)
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)
3
+ ![Build Status](https://github.com/Unleash/unleash-client-ruby/actions/workflows/pull_request.yml/badge.svg?branch=main)
4
+ [![Coverage Status](https://coveralls.io/repos/github/Unleash/unleash-client-ruby/badge.svg?branch=main)](https://coveralls.io/github/Unleash/unleash-client-ruby?branch=main)
5
5
  [![Gem Version](https://badge.fury.io/rb/unleash.svg)](https://badge.fury.io/rb/unleash)
6
6
 
7
7
  Unleash client so you can roll out your features with confidence.
@@ -10,11 +10,13 @@ Leverage the [Unleash Server](https://github.com/Unleash/unleash) for powerful f
10
10
 
11
11
  ## Supported Ruby Interpreters
12
12
 
13
+ * MRI 3.1
13
14
  * MRI 3.0
14
15
  * MRI 2.7
15
16
  * MRI 2.6
16
17
  * MRI 2.5
17
- * jruby
18
+ * jruby 9.3
19
+ * jruby 9.2
18
20
 
19
21
  ## Installation
20
22
 
@@ -69,18 +71,23 @@ Argument | Description | Required? | Type | Default Value|
69
71
  `environment` | Environment the program is running on. Could be for example `prod` or `dev`. Not yet in use. | N | String | `default` |
70
72
  `project_name` | Name of the project to retrieve features from. If not set, all feature flags will be retrieved. | N | String | nil |
71
73
  `refresh_interval` | How often the unleash client should check with the server for configuration changes. | N | Integer | 15 |
72
- `metrics_interval` | How often the unleash client should send metrics to server. | N | Integer | 30 |
74
+ `metrics_interval` | How often the unleash client should send metrics to server. | N | Integer | 60 |
73
75
  `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` |
74
76
  `disable_metrics` | Disables sending metrics to Unleash server. | N | Boolean | `false` |
75
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 | {} |
76
78
  `timeout` | How long to wait for the connection to be established or wait in reading state (open_timeout/read_timeout) | N | Integer | 30 |
77
- `retry_limit` | How many consecutive failures in connecting to the Unleash server are allowed before giving up. Use `Float::INFINITY` if you would like it to never give up. | N | Numeric | 5 |
79
+ `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 |
78
80
  `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` |
79
81
  `logger` | Specify a custom `Logger` class to handle logs for the Unleash client. | N | Class | `Logger.new(STDOUT)` |
80
82
  `log_level` | Change the log level for the `Logger` class. Constant from `Logger::Severity`. | N | Constant | `Logger::WARN` |
83
+ `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` |
81
84
 
82
- For in a more in depth look, please see `lib/unleash/configuration.rb`.
85
+ For a more in-depth look, please see `lib/unleash/configuration.rb`.
83
86
 
87
+ Environment Variable | Description
88
+ ---------|---------
89
+ `UNLEASH_BOOTSTRAP_FILE` | File to read bootstrap data from
90
+ `UNLEASH_BOOTSTRAP_URL` | URL to read bootstrap data from
84
91
 
85
92
  ## Usage in a plain Ruby Application
86
93
 
@@ -88,7 +95,7 @@ For in a more in depth look, please see `lib/unleash/configuration.rb`.
88
95
  require 'unleash'
89
96
  require 'unleash/context'
90
97
 
91
- @unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: 'http://unleash.herokuapp.com/api', custom_http_headers: {'Authorization': '<API token>'})
98
+ @unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: 'http://unleash.herokuapp.com/api', custom_http_headers: { 'Authorization': '<API token>' })
92
99
 
93
100
  feature_name = "AwesomeFeature"
94
101
  unleash_context = Unleash::Context.new
@@ -117,29 +124,97 @@ Unleash.configure do |config|
117
124
  end
118
125
 
119
126
  UNLEASH = Unleash::Client.new
127
+
128
+ # Or if preferred:
129
+ # Rails.configuration.unleash = Unleash::Client.new
130
+ ```
131
+ For `config.instance_id` use a string with a unique identification for the running instance.
132
+ For example: it could be the hostname, if you only run one App per host.
133
+ Or the docker container id, if you are running in docker.
134
+ If it is not set the client will generate an unique UUID for each execution.
135
+
136
+ To have it available in the `rails console` command as well, also add to the file above:
137
+ ```ruby
138
+ Rails.application.console do
139
+ UNLEASH = Unleash::Client.new
140
+ # or
141
+ # Rails.configuration.unleash = Unleash::Client.new
142
+ end
143
+ ```
144
+
145
+ #### Add Initializer if using [Puma in clustered mode](https://github.com/puma/puma#clustered-mode)
146
+
147
+ That is, multiple workers configured in `puma.rb`:
148
+ ```ruby
149
+ workers ENV.fetch("WEB_CONCURRENCY") { 2 }
120
150
  ```
121
- For `config.instance_id` use a string with a unique identification for the running instance. For example: it could be the hostname, if you only run one App per host. Or the docker container id, if you are running in docker. If it is not set the client will generate an unique UUID for each execution.
122
151
 
152
+ ##### with `preload_app!`
123
153
 
124
- #### Add Initializer if using [Puma](https://github.com/puma/puma)
154
+ Then you may keep the client configuration still in `config/initializers/unleash.rb`:
155
+ ```ruby
156
+ Unleash.configure do |config|
157
+ config.app_name = Rails.application.class.parent.to_s
158
+ config.environment = Rails.env
159
+ config.url = 'http://unleash.herokuapp.com/api'
160
+ config.custom_http_headers = {'Authorization': '<API token>'}
161
+ end
162
+ ```
125
163
 
126
- In `puma.rb` ensure that the unleash client is configured and instantiated as below, inside the `on_worker_boot` code block:
164
+ But you must ensure that the unleash client is instantiated only after the process is forked.
165
+ This is done by creating the client inside the `on_worker_boot` code block in `puma.rb` as below:
127
166
 
128
167
  ```ruby
168
+ #...
169
+ preload_app!
170
+ #...
171
+
129
172
  on_worker_boot do
130
173
  # ...
131
174
 
132
- Unleash.configure do |config|
133
- config.app_name = Rails.application.class.parent.to_s
134
- config.environment = Rails.env
135
- config.url = 'http://unleash.herokuapp.com/api'
136
- config.custom_http_headers = {'Authorization': '<API token>'}
137
- end
138
- Rails.configuration.unleash = Unleash::Client.new
175
+ ::UNLEASH = Unleash::Client.new
176
+ end
177
+
178
+ on_worker_shutdown do
179
+ ::UNLEASH.shutdown
180
+ end
181
+ ```
182
+
183
+ ##### without `preload_app!`
184
+
185
+ By not using `preload_app!`:
186
+ - the `Rails` constant will NOT be available.
187
+ - but phased restarts will be possible.
188
+
189
+ You need to ensure that in `puma.rb`:
190
+ - loading unleash sdk with `require 'unleash'` explicitly, as it will not be pre-loaded.
191
+ - all parameters must be explicitly set in the `on_worker_boot` block, as `config/initializers/unleash.rb` is not read.
192
+ - there are no references to `Rails` constant, as that is not yet available.
193
+
194
+ Example for `puma.rb`:
195
+ ```ruby
196
+ require 'unleash'
197
+
198
+ #...
199
+ # no preload_app!
200
+
201
+ on_worker_boot do
202
+ # ...
203
+
204
+ ::UNLEASH = Unleash::Client.new(
205
+ app_name: 'my_rails_app',
206
+ environment: 'development',
207
+ url: 'http://unleash.herokuapp.com/api',
208
+ custom_http_headers: {'Authorization': '<API token>'},
209
+ )
210
+ end
211
+
212
+ on_worker_shutdown do
213
+ ::UNLEASH.shutdown
139
214
  end
140
215
  ```
141
216
 
142
- Instead of the configuration in `config/initializers/unleash.rb`.
217
+ Note that we also added shutdown hooks in `on_worker_shutdown`, to ensure a clean shutdown.
143
218
 
144
219
  #### Add Initializer if using [Phusion Passenger](https://github.com/phusion/passenger)
145
220
 
@@ -164,6 +239,23 @@ PhusionPassenger.on_event(:starting_worker_process) do |forked|
164
239
  end
165
240
  ```
166
241
 
242
+ #### Add Initializer hooks when using within [Sidekiq](https://github.com/mperham/sidekiq)
243
+
244
+ Note that in this case we require that the code block for `Unleash.configure` is set beforehand.
245
+ For example in `config/initializers/unleash.rb`.
246
+
247
+ ```ruby
248
+ Sidekiq.configure_server do |config|
249
+ config.on(:startup) do
250
+ UNLEASH = Unleash::Client.new
251
+ end
252
+
253
+ config.on(:shutdown) do
254
+ UNLEASH.shutdown
255
+ end
256
+ end
257
+ ```
258
+
167
259
  #### Set Unleash::Context
168
260
 
169
261
  Be sure to add the following method and callback in the application controller to have `@unleash_context` set for all requests:
@@ -203,7 +295,8 @@ if Rails.configuration.unleash.is_enabled? "AwesomeFeature", @unleash_context
203
295
  end
204
296
  ```
205
297
 
206
- If the feature is not found in the server, it will by default return false. However you can override that by setting the default return value to `true`:
298
+ If the feature is not found in the server, it will by default return false.
299
+ However you can override that by setting the default return value to `true`:
207
300
 
208
301
  ```ruby
209
302
  if UNLEASH.is_enabled? "AwesomeFeature", @unleash_context, true
@@ -266,6 +359,65 @@ variant = UNLEASH.get_variant "ColorVariants", @unleash_context, fallback_varian
266
359
  puts "variant color is: #{variant.payload.fetch('color')}"
267
360
  ```
268
361
 
362
+ ## Bootstrapping
363
+
364
+ Bootstrap configuration allows the client to be initialized with a predefined set of toggle states.
365
+ Bootstrapping can be configured by providing a bootstrap configuration when initializing the client.
366
+ ```ruby
367
+ @unleash = Unleash::Client.new(
368
+ url: 'http://unleash.herokuapp.com/api',
369
+ app_name: 'my_ruby_app',
370
+ custom_http_headers: { 'Authorization': '<API token>' },
371
+ bootstrap_config: Unleash::Bootstrap::Configuration.new({
372
+ url: "http://unleash.herokuapp.com/api/client/features",
373
+ url_headers: {'Authorization': '<API token>'}
374
+ })
375
+ )
376
+ ```
377
+ The `Bootstrap::Configuration` initializer takes a hash with one of the following options specified:
378
+
379
+ * `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.
380
+ * `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.
381
+ * `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.
382
+ * `data` - A raw JSON string as returned by the Unleash server.
383
+ * `block` - A lambda containing custom logic if you need it, an example is provided below.
384
+
385
+ You should only specify one type of bootstrapping since only one will be invoked and the others will be ignored.
386
+ The order of preference is as follows:
387
+
388
+ - Select a data bootstrapper if it exists.
389
+ - If no data bootstrapper exists, select the block bootstrapper.
390
+ - If no block bootstrapper exists, select the file bootstrapper from either parameters or the specified environment variable.
391
+ - If no file bootstrapper exists, then check for a URL bootstrapper from either the parameters or the specified environment variable.
392
+
393
+
394
+ Example usage:
395
+
396
+ First saving the toggles locally:
397
+ ```shell
398
+ curl -H 'Authorization: <API token>' -XGET 'http://unleash.herokuapp.com/api' > ./default-toggles.json
399
+ ```
400
+
401
+ Now using them on start up:
402
+
403
+ ```ruby
404
+
405
+ custom_boostrapper = lambda {
406
+ File.read('./default-toggles.json')
407
+ }
408
+
409
+ @unleash = Unleash::Client.new(
410
+ app_name: 'my_ruby_app',
411
+ url: 'http://unleash.herokuapp.com/api',
412
+ custom_http_headers: { 'Authorization': '<API token>' },
413
+ bootstrap_config: Unleash::Bootstrap::Configuration.new({
414
+ block: custom_boostrapper
415
+ })
416
+ )
417
+ ```
418
+
419
+ This example could be easily achieved with a file bootstrapper, this is just to illustrate the usage of custom bootstrapping.
420
+ Be aware that the client initializer will block until bootstrapping is complete.
269
421
 
270
422
  #### Client methods
271
423
 
@@ -304,7 +456,6 @@ This client comes with the all the required strategies out of the box:
304
456
  * UnknownStrategy
305
457
  * UserWithIdStrategy
306
458
 
307
-
308
459
  ## Development
309
460
 
310
461
  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.
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'unleash'
4
+ require 'unleash/context'
5
+ require 'unleash/bootstrap/configuration'
6
+
7
+ puts ">> START bootstrap.rb"
8
+
9
+ @unleash = Unleash::Client.new(
10
+ url: 'http://unleash.herokuapp.com/api',
11
+ custom_http_headers: { 'Authorization': '943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0' },
12
+ app_name: 'bootstrap-test',
13
+ instance_id: 'local-test-cli',
14
+ refresh_interval: 2,
15
+ disable_client: true,
16
+ disable_metrics: true,
17
+ metrics_interval: 2,
18
+ retry_limit: 2,
19
+ bootstrap_config: Unleash::Bootstrap::Configuration.new(file_path: "examples/default-toggles.json")
20
+ )
21
+
22
+ feature_name = "featureX"
23
+ unleash_context = Unleash::Context.new
24
+ unleash_context.user_id = 123
25
+
26
+ sleep 1
27
+ 3.times do
28
+ if @unleash.is_enabled?(feature_name, unleash_context)
29
+ puts "> #{feature_name} is enabled"
30
+ else
31
+ puts "> #{feature_name} is not enabled"
32
+ end
33
+ sleep 1
34
+ puts "---"
35
+ puts ""
36
+ puts ""
37
+ end
38
+
39
+ sleep 3
40
+ feature_name = "foobar"
41
+ if @unleash.is_enabled?(feature_name, unleash_context, true)
42
+ puts "> #{feature_name} is enabled"
43
+ else
44
+ puts "> #{feature_name} is not enabled"
45
+ end
46
+
47
+ puts "> shutting down client..."
48
+
49
+ @unleash.shutdown
50
+
51
+ puts ">> END bootstrap.rb"
@@ -0,0 +1,42 @@
1
+ {
2
+ "version": 1,
3
+ "features": [
4
+ {
5
+ "name": "featureX",
6
+ "enabled": true,
7
+ "strategies": [
8
+ {
9
+ "name": "default"
10
+ }
11
+ ]
12
+ },
13
+ {
14
+ "name": "featureY",
15
+ "enabled": false,
16
+ "strategies": [
17
+ {
18
+ "name": "baz",
19
+ "parameters": {
20
+ "foo": "bar"
21
+ }
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "name": "featureZ",
27
+ "enabled": true,
28
+ "strategies": [
29
+ {
30
+ "name": "default"
31
+ },
32
+ {
33
+ "name": "hola",
34
+ "parameters": {
35
+ "name": "val"
36
+ }
37
+ }
38
+ ]
39
+ }
40
+ ]
41
+ }
42
+
@@ -0,0 +1,25 @@
1
+ module Unleash
2
+ module Bootstrap
3
+ class Configuration
4
+ attr_accessor :data, :file_path, :url, :url_headers, :block
5
+
6
+ def initialize(opts = {})
7
+ self.file_path = resolve_value_indifferently(opts, 'file_path') || ENV['UNLEASH_BOOTSTRAP_FILE'] || nil
8
+ self.url = resolve_value_indifferently(opts, 'url') || ENV['UNLEASH_BOOTSTRAP_URL'] || nil
9
+ self.url_headers = resolve_value_indifferently(opts, 'url_headers')
10
+ self.data = resolve_value_indifferently(opts, 'data')
11
+ self.block = resolve_value_indifferently(opts, 'block')
12
+ end
13
+
14
+ def valid?
15
+ ![self.data, self.file_path, self.url, self.block].all?(&:nil?)
16
+ end
17
+
18
+ private
19
+
20
+ def resolve_value_indifferently(opts, key)
21
+ opts[key] || opts[key.to_sym]
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ require 'unleash/bootstrap/provider/from_url'
2
+ require 'unleash/bootstrap/provider/from_file'
3
+
4
+ module Unleash
5
+ module Bootstrap
6
+ class Handler
7
+ attr_accessor :configuration
8
+
9
+ def initialize(configuration)
10
+ self.configuration = configuration
11
+ end
12
+
13
+ # @return [String] JSON string representing data returned from an Unleash server
14
+ def retrieve_toggles
15
+ return configuration.data unless self.configuration.data.nil?
16
+ return configuration.block.call if self.configuration.block.is_a?(Proc)
17
+ return Provider::FromFile.read(configuration.file_path) unless self.configuration.file_path.nil?
18
+ return Provider::FromUrl.read(configuration.url, configuration.url_headers) unless self.configuration.url.nil?
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ module Unleash
2
+ module Bootstrap
3
+ module Provider
4
+ class NotImplemented < RuntimeError
5
+ end
6
+
7
+ class Base
8
+ def read
9
+ raise NotImplemented, "Bootstrap is not implemented"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ require 'unleash/bootstrap/provider/base'
2
+
3
+ module Unleash
4
+ module Bootstrap
5
+ module Provider
6
+ class FromFile < Base
7
+ # @param file_path [String]
8
+ def self.read(file_path)
9
+ File.read(file_path)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ require 'unleash/bootstrap/provider/base'
2
+
3
+ module Unleash
4
+ module Bootstrap
5
+ module Provider
6
+ class FromUrl < Base
7
+ # @param url [String]
8
+ # @param headers [Hash, nil] HTTP headers to use. If not set, the unleash client SDK ones will be used.
9
+ def self.read(url, headers = nil)
10
+ response = Unleash::Util::Http.get(URI.parse(url), nil, headers)
11
+
12
+ return nil if response.code != '200'
13
+
14
+ response.body
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -18,8 +18,9 @@ module Unleash
18
18
  Unleash.logger = Unleash.configuration.logger.clone
19
19
  Unleash.logger.level = Unleash.configuration.log_level
20
20
 
21
+ Unleash.toggle_fetcher = Unleash::ToggleFetcher.new
21
22
  if Unleash.configuration.disable_client
22
- Unleash.logger.warn "Unleash::Client is disabled! Will only return default results!"
23
+ Unleash.logger.warn "Unleash::Client is disabled! Will only return default (or bootstrapped if available) results!"
23
24
  return
24
25
  end
25
26
 
@@ -37,11 +38,6 @@ module Unleash
37
38
  default_value_param
38
39
  end
39
40
 
40
- if Unleash.configuration.disable_client
41
- Unleash.logger.warn "unleash_client is disabled! Always returning #{default_value} for feature #{feature}!"
42
- return default_value
43
- end
44
-
45
41
  toggle_as_hash = Unleash&.toggles&.select{ |toggle| toggle['name'] == feature }&.first
46
42
 
47
43
  if toggle_as_hash.nil?
@@ -51,7 +47,7 @@ module Unleash
51
47
 
52
48
  toggle = Unleash::FeatureToggle.new(toggle_as_hash)
53
49
 
54
- toggle.is_enabled?(context, default_value)
50
+ toggle.is_enabled?(context)
55
51
  end
56
52
 
57
53
  # enabled? is a more ruby idiomatic method name than is_enabled?
@@ -121,11 +117,11 @@ module Unleash
121
117
  end
122
118
 
123
119
  def start_toggle_fetcher
124
- Unleash.toggle_fetcher = Unleash::ToggleFetcher.new
125
120
  self.fetcher_scheduled_executor = Unleash::ScheduledExecutor.new(
126
121
  'ToggleFetcher',
127
122
  Unleash.configuration.refresh_interval,
128
- Unleash.configuration.retry_limit
123
+ Unleash.configuration.retry_limit,
124
+ first_fetch_is_eager
129
125
  )
130
126
  self.fetcher_scheduled_executor.run do
131
127
  Unleash.toggle_fetcher.fetch
@@ -161,5 +157,9 @@ module Unleash
161
157
  def disabled_variant
162
158
  @disabled_variant ||= Unleash::FeatureToggle.disabled_variant
163
159
  end
160
+
161
+ def first_fetch_is_eager
162
+ Unleash.configuration.use_bootstrap?
163
+ end
164
164
  end
165
165
  end
@@ -1,5 +1,6 @@
1
1
  require 'securerandom'
2
2
  require 'tmpdir'
3
+ require 'unleash/bootstrap/configuration'
3
4
 
4
5
  module Unleash
5
6
  class Configuration
@@ -18,7 +19,8 @@ module Unleash
18
19
  :metrics_interval,
19
20
  :backup_file,
20
21
  :logger,
21
- :log_level
22
+ :log_level,
23
+ :bootstrap_config
22
24
 
23
25
  def initialize(opts = {})
24
26
  ensure_valid_opts(opts)
@@ -70,6 +72,10 @@ module Unleash
70
72
  self.url.delete_suffix '/'
71
73
  end
72
74
 
75
+ def use_bootstrap?
76
+ self.bootstrap_config&.valid?
77
+ end
78
+
73
79
  private
74
80
 
75
81
  def ensure_valid_opts(opts)
@@ -87,11 +93,12 @@ module Unleash
87
93
  self.disable_client = false
88
94
  self.disable_metrics = false
89
95
  self.refresh_interval = 10
90
- self.metrics_interval = 30
96
+ self.metrics_interval = 60
91
97
  self.timeout = 30
92
- self.retry_limit = 1
98
+ self.retry_limit = Float::INFINITY
93
99
  self.backup_file = nil
94
100
  self.log_level = Logger::WARN
101
+ self.bootstrap_config = nil
95
102
 
96
103
  self.custom_http_headers = {}
97
104
  end
@@ -1,26 +1,104 @@
1
+ require 'date'
2
+
1
3
  module Unleash
2
4
  class Constraint
3
- attr_accessor :context_name, :operator, :values
5
+ attr_accessor :context_name, :operator, :value, :inverted, :case_insensitive
6
+
7
+ OPERATORS = {
8
+ IN: ->(context_v, constraint_v){ constraint_v.include? context_v },
9
+ NOT_IN: ->(context_v, constraint_v){ !constraint_v.include? context_v },
10
+ STR_STARTS_WITH: ->(context_v, constraint_v){ constraint_v.any?{ |v| context_v.start_with? v } },
11
+ STR_ENDS_WITH: ->(context_v, constraint_v){ constraint_v.any?{ |v| context_v.end_with? v } },
12
+ STR_CONTAINS: ->(context_v, constraint_v){ constraint_v.any?{ |v| context_v.include? v } },
13
+ NUM_EQ: ->(context_v, constraint_v){ on_valid_float(constraint_v, context_v){ |x, y| (x - y).abs < Float::EPSILON } },
14
+ NUM_LT: ->(context_v, constraint_v){ on_valid_float(constraint_v, context_v){ |x, y| (x > y) } },
15
+ NUM_LTE: ->(context_v, constraint_v){ on_valid_float(constraint_v, context_v){ |x, y| (x >= y) } },
16
+ NUM_GT: ->(context_v, constraint_v){ on_valid_float(constraint_v, context_v){ |x, y| (x < y) } },
17
+ NUM_GTE: ->(context_v, constraint_v){ on_valid_float(constraint_v, context_v){ |x, y| (x <= y) } },
18
+ DATE_AFTER: ->(context_v, constraint_v){ on_valid_date(constraint_v, context_v){ |x, y| (x < y) } },
19
+ DATE_BEFORE: ->(context_v, constraint_v){ on_valid_date(constraint_v, context_v){ |x, y| (x > y) } },
20
+ SEMVER_EQ: ->(context_v, constraint_v){ on_valid_version(constraint_v, context_v){ |x, y| (x == y) } },
21
+ 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) } }
23
+ }.freeze
4
24
 
5
- VALID_OPERATORS = ['IN', 'NOT_IN'].freeze
25
+ LIST_OPERATORS = [:IN, :NOT_IN, :STR_STARTS_WITH, :STR_ENDS_WITH, :STR_CONTAINS].freeze
6
26
 
7
- def initialize(context_name, operator, values = [])
27
+ def initialize(context_name, operator, value = [], inverted: false, case_insensitive: false)
8
28
  raise ArgumentError, "context_name is not a String" unless context_name.is_a?(String)
9
- raise ArgumentError, "operator does not hold a valid value:" + VALID_OPERATORS unless VALID_OPERATORS.include? operator
10
- raise ArgumentError, "values does not hold an Array" unless values.is_a?(Array)
29
+ raise ArgumentError, "operator does not hold a valid value:" + OPERATORS.keys unless OPERATORS.include? operator.to_sym
30
+
31
+ self.validate_constraint_value_type(operator.to_sym, value)
11
32
 
12
33
  self.context_name = context_name
13
- self.operator = operator
14
- self.values = values
34
+ self.operator = operator.to_sym
35
+ self.value = value
36
+ self.inverted = !!inverted
37
+ self.case_insensitive = !!case_insensitive
15
38
  end
16
39
 
17
40
  def matches_context?(context)
18
- Unleash.logger.debug "Unleash::Constraint matches_context? values: #{self.values} context.get_by_name(#{self.context_name})" \
41
+ Unleash.logger.debug "Unleash::Constraint matches_context? value: #{self.value} context.get_by_name(#{self.context_name})" \
19
42
  " #{context.get_by_name(self.context_name)} "
43
+ match = matches_constraint?(context)
44
+ self.inverted ? !match : match
45
+ rescue KeyError
46
+ Unleash.logger.warn "Attemped to resolve a context key during constraint resolution: #{self.context_name} but it wasn't \
47
+ found on the context"
48
+ false
49
+ end
50
+
51
+ def self.on_valid_date(val1, val2)
52
+ val1 = DateTime.parse(val1)
53
+ val2 = DateTime.parse(val2)
54
+ yield(val1, val2)
55
+ rescue ArgumentError
56
+ Unleash.logger.warn "Unleash::ConstraintMatcher unable to parse either context_value (#{val1}) \
57
+ or constraint_value (#{val2}) into a date. Returning false!"
58
+ false
59
+ end
60
+
61
+ def self.on_valid_float(val1, val2)
62
+ val1 = Float(val1)
63
+ val2 = Float(val2)
64
+ yield(val1, val2)
65
+ rescue ArgumentError
66
+ Unleash.logger.warn "Unleash::ConstraintMatcher unable to parse either context_value (#{val1}) \
67
+ or constraint_value (#{val2}) into a number. Returning false!"
68
+ false
69
+ end
70
+
71
+ def self.on_valid_version(val1, val2)
72
+ val1 = Gem::Version.new(val1)
73
+ val2 = Gem::Version.new(val2)
74
+ yield(val1, val2)
75
+ rescue ArgumentError
76
+ Unleash.logger.warn "Unleash::ConstraintMatcher unable to parse either context_value (#{val1}) \
77
+ or constraint_value (#{val2}) into a version. Return false!"
78
+ false
79
+ end
80
+
81
+ # 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)
85
+ end
86
+
87
+ private
88
+
89
+ def matches_constraint?(context)
90
+ unless OPERATORS.include?(self.operator)
91
+ Unleash.logger.warn "Invalid constraint operator: #{self.operator}, this should be unreachable. Always returning false."
92
+ false
93
+ end
94
+
95
+ v = self.value.dup
96
+ context_value = context.get_by_name(self.context_name)
20
97
 
21
- is_included = self.values.include? context.get_by_name(self.context_name)
98
+ v.map!(&:upcase) if self.case_insensitive
99
+ context_value.upcase! if self.case_insensitive
22
100
 
23
- operator == 'IN' ? is_included : !is_included
101
+ OPERATORS[self.operator].call(context_value, v)
24
102
  end
25
103
  end
26
104
  end
@@ -1,6 +1,6 @@
1
1
  module Unleash
2
2
  class Context
3
- ATTRS = [:app_name, :environment, :user_id, :session_id, :remote_address].freeze
3
+ ATTRS = [:app_name, :environment, :user_id, :session_id, :remote_address, :current_time].freeze
4
4
 
5
5
  attr_accessor(*[ATTRS, :properties].flatten)
6
6
 
@@ -12,6 +12,7 @@ module Unleash
12
12
  self.user_id = value_for('userId', params)
13
13
  self.session_id = value_for('sessionId', params)
14
14
  self.remote_address = value_for('remoteAddress', params)
15
+ self.current_time = value_for('currentTime', params, Time.now.utc.iso8601.to_s)
15
16
 
16
17
  properties = value_for('properties', params)
17
18
  self.properties = properties.is_a?(Hash) ? properties.transform_keys(&:to_sym) : {}
@@ -23,8 +23,8 @@ module Unleash
23
23
  "<FeatureToggle: name=#{name},enabled=#{enabled},strategies=#{strategies},variant_definitions=#{variant_definitions}>"
24
24
  end
25
25
 
26
- def is_enabled?(context, default_result)
27
- result = am_enabled?(context, default_result)
26
+ def is_enabled?(context)
27
+ result = am_enabled?(context)
28
28
 
29
29
  choice = result ? :yes : :no
30
30
  Unleash.toggle_metrics.increment(name, choice) unless Unleash.configuration.disable_metrics
@@ -37,7 +37,7 @@ module Unleash
37
37
 
38
38
  context = ensure_valid_context(context)
39
39
 
40
- return Unleash::FeatureToggle.disabled_variant unless self.enabled && am_enabled?(context, true)
40
+ return Unleash::FeatureToggle.disabled_variant unless self.enabled && am_enabled?(context)
41
41
  return Unleash::FeatureToggle.disabled_variant if sum_variant_defs_weights <= 0
42
42
 
43
43
  variant = variant_from_override_match(context) || variant_from_weights(context, resolve_stickiness)
@@ -57,7 +57,7 @@ module Unleash
57
57
  end
58
58
 
59
59
  # only check if it is enabled, do not do metrics
60
- def am_enabled?(context, default_result)
60
+ def am_enabled?(context)
61
61
  result =
62
62
  if self.enabled
63
63
  self.strategies.empty? ||
@@ -65,10 +65,10 @@ module Unleash
65
65
  strategy_enabled?(s, context) && strategy_constraint_matches?(s, context)
66
66
  end
67
67
  else
68
- default_result
68
+ false
69
69
  end
70
70
 
71
- Unleash.logger.debug "Unleash::FeatureToggle (enabled:#{self.enabled} default_result:#{default_result} " \
71
+ Unleash.logger.debug "Unleash::FeatureToggle (enabled:#{self.enabled} " \
72
72
  "and Strategies combined with contraints returned #{result})"
73
73
 
74
74
  result
@@ -139,7 +139,9 @@ module Unleash
139
139
  Constraint.new(
140
140
  c.fetch('contextName'),
141
141
  c.fetch('operator'),
142
- c.fetch('values')
142
+ c.fetch('values', nil) || c.fetch('value', nil),
143
+ inverted: c.fetch('inverted', false),
144
+ case_insensitive: c.fetch('caseInsensitive', false)
143
145
  )
144
146
  end
145
147
  )
@@ -1,19 +1,22 @@
1
1
  module Unleash
2
2
  class ScheduledExecutor
3
- attr_accessor :name, :interval, :max_exceptions, :retry_count, :thread
3
+ attr_accessor :name, :interval, :max_exceptions, :retry_count, :thread, :immediate_execution
4
4
 
5
- def initialize(name, interval, max_exceptions = 5)
5
+ def initialize(name, interval, max_exceptions = 5, immediate_execution = false)
6
6
  self.name = name || ''
7
7
  self.interval = interval
8
8
  self.max_exceptions = max_exceptions
9
9
  self.retry_count = 0
10
10
  self.thread = nil
11
+ self.immediate_execution = immediate_execution
11
12
  end
12
13
 
13
14
  def run(&blk)
14
15
  self.thread = Thread.new do
15
16
  Thread.current[:name] = self.name
16
17
 
18
+ run_blk{ blk.call } if self.immediate_execution
19
+
17
20
  Unleash.logger.debug "thread #{name} loop starting"
18
21
  loop do
19
22
  Unleash.logger.debug "thread #{name} sleeping for #{interval} seconds"
@@ -13,7 +13,23 @@ module Unleash
13
13
  return false unless params.fetch(PARAM, nil).is_a? String
14
14
  return false unless context.class.name == 'Unleash::Context'
15
15
 
16
- params[PARAM].split(',').map(&:strip).include?(context.remote_address)
16
+ remote_address = ipaddr_or_nil_from_str(context.remote_address)
17
+
18
+ params[PARAM]
19
+ .split(',')
20
+ .map(&:strip)
21
+ .map{ |ipblock| ipaddr_or_nil_from_str(ipblock) }
22
+ .compact
23
+ .map{ |ipb| ipb.include? remote_address }
24
+ .any?
25
+ end
26
+
27
+ private
28
+
29
+ def ipaddr_or_nil_from_str(ip)
30
+ IPAddr.new(ip)
31
+ rescue StandardError
32
+ nil
17
33
  end
18
34
  end
19
35
  end
@@ -1,4 +1,5 @@
1
1
  require 'unleash/configuration'
2
+ require 'unleash/bootstrap/handler'
2
3
  require 'net/http'
3
4
  require 'json'
4
5
 
@@ -13,10 +14,15 @@ module Unleash
13
14
  self.toggle_resource = ConditionVariable.new
14
15
  self.retry_count = 0
15
16
 
16
- # start by fetching synchronously, and failing back to reading the backup file.
17
17
  begin
18
- fetch
18
+ # if bootstrap configuration is available, initialize. An immediate API read is also triggered
19
+ if Unleash.configuration.use_bootstrap?
20
+ bootstrap
21
+ else
22
+ fetch
23
+ end
19
24
  rescue StandardError => e
25
+ # fail back to reading the backup file
20
26
  Unleash.logger.warn "ToggleFetcher was unable to fetch from the network, attempting to read from backup file."
21
27
  Unleash.logger.debug "Exception Caught: #{e}"
22
28
  read!
@@ -36,6 +42,8 @@ module Unleash
36
42
  # rename to refresh_from_server! ??
37
43
  def fetch
38
44
  Unleash.logger.debug "fetch()"
45
+ return if Unleash.configuration.disable_client
46
+
39
47
  response = Unleash::Util::Http.get(Unleash.configuration.fetch_toggles_uri, etag)
40
48
 
41
49
  if response.code == '304'
@@ -46,14 +54,7 @@ module Unleash
46
54
  end
47
55
 
48
56
  self.etag = response['ETag']
49
- response_hash = JSON.parse(response.body)
50
-
51
- if response_hash['version'] >= 1
52
- features = response_hash['features']
53
- else
54
- raise NotImplemented, "Version of features provided by unleash server" \
55
- " is unsupported by this client."
56
- end
57
+ features = get_features(response.body)
57
58
 
58
59
  # always synchronize with the local cache when fetching:
59
60
  synchronize_with_local_cache!(features)
@@ -126,5 +127,23 @@ module Unleash
126
127
  file&.close
127
128
  end
128
129
  end
130
+
131
+ def bootstrap
132
+ bootstrap_payload = Unleash::Bootstrap::Handler.new(Unleash.configuration.bootstrap_config).retrieve_toggles
133
+ synchronize_with_local_cache! get_features bootstrap_payload
134
+ update_running_client!
135
+
136
+ # reset Unleash.configuration.bootstrap_data to free up memory, as we will never use it again
137
+ Unleash.configuration.bootstrap_config = nil
138
+ end
139
+
140
+ # @param response_body [String]
141
+ def get_features(response_body)
142
+ response_hash = JSON.parse(response_body)
143
+ return response_hash['features'] if response_hash['version'] >= 1
144
+
145
+ raise NotImplemented, "Version of features provided by unleash server" \
146
+ " is unsupported by this client."
147
+ end
129
148
  end
130
149
  end
@@ -4,10 +4,10 @@ require 'uri'
4
4
  module Unleash
5
5
  module Util
6
6
  module Http
7
- def self.get(uri, etag = nil)
7
+ def self.get(uri, etag = nil, headers_override = nil)
8
8
  http = http_connection(uri)
9
9
 
10
- request = Net::HTTP::Get.new(uri.request_uri, http_headers(etag))
10
+ request = Net::HTTP::Get.new(uri.request_uri, http_headers(etag, headers_override))
11
11
 
12
12
  http.request(request)
13
13
  end
@@ -30,10 +30,13 @@ module Unleash
30
30
  http
31
31
  end
32
32
 
33
- def self.http_headers(etag = nil)
33
+ # @param etag [String, nil]
34
+ # @param headers_override [Hash, nil]
35
+ def self.http_headers(etag = nil, headers_override = nil)
34
36
  Unleash.logger.debug "ETag: #{etag}" unless etag.nil?
35
37
 
36
38
  headers = (Unleash.configuration.http_headers || {}).dup
39
+ headers = headers_override if headers_override.is_a?(Hash)
37
40
  headers['Content-Type'] = 'application/json'
38
41
  headers['If-None-Match'] = etag unless etag.nil?
39
42
 
@@ -1,3 +1,3 @@
1
1
  module Unleash
2
- VERSION = "4.0.0".freeze
2
+ VERSION = "4.2.1".freeze
3
3
  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.0.0
4
+ version: 4.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Renato Arruda
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-12-16 00:00:00.000000000 Z
11
+ date: 2022-03-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: murmurhash3
@@ -157,9 +157,16 @@ files:
157
157
  - bin/console
158
158
  - bin/setup
159
159
  - bin/unleash-client
160
+ - examples/bootstrap.rb
161
+ - examples/default-toggles.json
160
162
  - examples/simple.rb
161
163
  - lib/unleash.rb
162
164
  - lib/unleash/activation_strategy.rb
165
+ - lib/unleash/bootstrap/configuration.rb
166
+ - lib/unleash/bootstrap/handler.rb
167
+ - lib/unleash/bootstrap/provider/base.rb
168
+ - lib/unleash/bootstrap/provider/from_file.rb
169
+ - lib/unleash/bootstrap/provider/from_url.rb
163
170
  - lib/unleash/client.rb
164
171
  - lib/unleash/configuration.rb
165
172
  - lib/unleash/constraint.rb
@@ -204,7 +211,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
204
211
  - !ruby/object:Gem::Version
205
212
  version: '0'
206
213
  requirements: []
207
- rubygems_version: 3.2.3
214
+ rubygems_version: 3.3.6
208
215
  signing_key:
209
216
  specification_version: 4
210
217
  summary: Unleash feature toggle client.