unleash 4.1.0 → 4.3.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: 14f988b84b07e45401ad61a5814c1ca44185fc9358cef33f050b1d3bae45dd4e
4
- data.tar.gz: afda9cf356088ef976047fe58d38d3b457fb1d8797cb7f519de6bdb953f43267
3
+ metadata.gz: f05a01bdf6013184b6948fb5c38f6b1da1cde9d18f9a0eeecd69b54cbed8ff18
4
+ data.tar.gz: 6343830fd945d00decd331acade205d605e0d496ed1d4afd4736affcb2f538ca
5
5
  SHA512:
6
- metadata.gz: c1ac85ea8b774a230212583b33b9fdab3110fadab2fa1652810ea0913312c1fd20abe4471761b5ccd399d66a52995c0ddb6b803ee42aa4c4432add8a5524e51d
7
- data.tar.gz: b03da5caf2bf25683b2d61599a37f15582d26664553ef72096dfd3073aceb96224eaa46fd19c948f9f4fc143e3226ee176a4e66d762016aedf736fb903edae85
6
+ metadata.gz: 1ac4a4b89f787b5ae27acda02865def0eb68f261da10b83c01a6b34f6706b1b29443375eb82418b9834db555fbe54c1a1dfe49e9bf4c761556145edce88f76c9
7
+ data.tar.gz: c7a2332c7e6ab54872b08432af4c5c2cf092b7586664eb0267e45e416fb9a72c46246dd6cdedf68b4a9421678e4cb9f9507365e40fc5c2113b9fba8e703a19dd
@@ -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.0.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.1.0 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/.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
@@ -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,9 +90,9 @@ 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
- `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 |
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` |
@@ -95,7 +111,7 @@ Environment Variable | Description
95
111
  require 'unleash'
96
112
  require 'unleash/context'
97
113
 
98
- @unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: 'http://unleash.herokuapp.com/api', custom_http_headers: { 'Authorization': '<API token>' })
114
+ @unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: 'https://unleash.herokuapp.com/api', custom_http_headers: { 'Authorization': '<API token>' })
99
115
 
100
116
  feature_name = "AwesomeFeature"
101
117
  unleash_context = Unleash::Context.new
@@ -117,36 +133,104 @@ Put in `config/initializers/unleash.rb`:
117
133
  ```ruby
118
134
  Unleash.configure do |config|
119
135
  config.app_name = Rails.application.class.parent.to_s
120
- config.url = 'http://unleash.herokuapp.com/api'
136
+ config.url = 'https://unleash.herokuapp.com/api'
121
137
  # config.instance_id = "#{Socket.gethostname}"
122
138
  config.logger = Rails.logger
123
139
  config.environment = Rails.env
124
140
  end
125
141
 
126
142
  UNLEASH = Unleash::Client.new
143
+
144
+ # Or if preferred:
145
+ # Rails.configuration.unleash = Unleash::Client.new
146
+ ```
147
+ For `config.instance_id` use a string with a unique identification for the running instance.
148
+ For example: it could be the hostname, if you only run one App per host.
149
+ Or the docker container id, if you are running in docker.
150
+ If it is not set the client will generate an unique UUID for each execution.
151
+
152
+ To have it available in the `rails console` command as well, also add to the file above:
153
+ ```ruby
154
+ Rails.application.console do
155
+ UNLEASH = Unleash::Client.new
156
+ # or
157
+ # Rails.configuration.unleash = Unleash::Client.new
158
+ end
127
159
  ```
128
- 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.
129
160
 
161
+ #### Add Initializer if using [Puma in clustered mode](https://github.com/puma/puma#clustered-mode)
130
162
 
131
- #### Add Initializer if using [Puma](https://github.com/puma/puma)
163
+ That is, multiple workers configured in `puma.rb`:
164
+ ```ruby
165
+ workers ENV.fetch("WEB_CONCURRENCY") { 2 }
166
+ ```
167
+
168
+ ##### with `preload_app!`
169
+
170
+ Then you may keep the client configuration still in `config/initializers/unleash.rb`:
171
+ ```ruby
172
+ Unleash.configure do |config|
173
+ config.app_name = Rails.application.class.parent.to_s
174
+ config.environment = Rails.env
175
+ config.url = 'https://unleash.herokuapp.com/api'
176
+ config.custom_http_headers = {'Authorization': '<API token>'}
177
+ end
178
+ ```
132
179
 
133
- In `puma.rb` ensure that the unleash client is configured and instantiated as below, inside the `on_worker_boot` code block:
180
+ But you must ensure that the unleash client is instantiated only after the process is forked.
181
+ This is done by creating the client inside the `on_worker_boot` code block in `puma.rb` as below:
134
182
 
135
183
  ```ruby
184
+ #...
185
+ preload_app!
186
+ #...
187
+
136
188
  on_worker_boot do
137
189
  # ...
138
190
 
139
- Unleash.configure do |config|
140
- config.app_name = Rails.application.class.parent.to_s
141
- config.environment = Rails.env
142
- config.url = 'http://unleash.herokuapp.com/api'
143
- config.custom_http_headers = {'Authorization': '<API token>'}
144
- end
145
- Rails.configuration.unleash = Unleash::Client.new
191
+ ::UNLEASH = Unleash::Client.new
192
+ end
193
+
194
+ on_worker_shutdown do
195
+ ::UNLEASH.shutdown
146
196
  end
147
197
  ```
148
198
 
149
- Instead of the configuration in `config/initializers/unleash.rb`.
199
+ ##### without `preload_app!`
200
+
201
+ By not using `preload_app!`:
202
+ - the `Rails` constant will NOT be available.
203
+ - but phased restarts will be possible.
204
+
205
+ You need to ensure that in `puma.rb`:
206
+ - loading unleash sdk with `require 'unleash'` explicitly, as it will not be pre-loaded.
207
+ - all parameters must be explicitly set in the `on_worker_boot` block, as `config/initializers/unleash.rb` is not read.
208
+ - there are no references to `Rails` constant, as that is not yet available.
209
+
210
+ Example for `puma.rb`:
211
+ ```ruby
212
+ require 'unleash'
213
+
214
+ #...
215
+ # no preload_app!
216
+
217
+ on_worker_boot do
218
+ # ...
219
+
220
+ ::UNLEASH = Unleash::Client.new(
221
+ app_name: 'my_rails_app',
222
+ environment: 'development',
223
+ url: 'https://unleash.herokuapp.com/api',
224
+ custom_http_headers: {'Authorization': '<API token>'},
225
+ )
226
+ end
227
+
228
+ on_worker_shutdown do
229
+ ::UNLEASH.shutdown
230
+ end
231
+ ```
232
+
233
+ Note that we also added shutdown hooks in `on_worker_shutdown`, to ensure a clean shutdown.
150
234
 
151
235
  #### Add Initializer if using [Phusion Passenger](https://github.com/phusion/passenger)
152
236
 
@@ -162,7 +246,7 @@ PhusionPassenger.on_event(:starting_worker_process) do |forked|
162
246
  # config.instance_id = "#{Socket.gethostname}"
163
247
  config.logger = Rails.logger
164
248
  config.environment = Rails.env
165
- config.url = 'http://unleash.herokuapp.com/api'
249
+ config.url = 'https://unleash.herokuapp.com/api'
166
250
  config.custom_http_headers = {'Authorization': '<API token>'}
167
251
  end
168
252
 
@@ -171,6 +255,23 @@ PhusionPassenger.on_event(:starting_worker_process) do |forked|
171
255
  end
172
256
  ```
173
257
 
258
+ #### Add Initializer hooks when using within [Sidekiq](https://github.com/mperham/sidekiq)
259
+
260
+ Note that in this case we require that the code block for `Unleash.configure` is set beforehand.
261
+ For example in `config/initializers/unleash.rb`.
262
+
263
+ ```ruby
264
+ Sidekiq.configure_server do |config|
265
+ config.on(:startup) do
266
+ UNLEASH = Unleash::Client.new
267
+ end
268
+
269
+ config.on(:shutdown) do
270
+ UNLEASH.shutdown
271
+ end
272
+ end
273
+ ```
274
+
174
275
  #### Set Unleash::Context
175
276
 
176
277
  Be sure to add the following method and callback in the application controller to have `@unleash_context` set for all requests:
@@ -210,7 +311,8 @@ if Rails.configuration.unleash.is_enabled? "AwesomeFeature", @unleash_context
210
311
  end
211
312
  ```
212
313
 
213
- 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`:
314
+ If the feature is not found in the server, it will by default return false.
315
+ However you can override that by setting the default return value to `true`:
214
316
 
215
317
  ```ruby
216
318
  if UNLEASH.is_enabled? "AwesomeFeature", @unleash_context, true
@@ -275,14 +377,15 @@ puts "variant color is: #{variant.payload.fetch('color')}"
275
377
 
276
378
  ## Bootstrapping
277
379
 
278
- Bootstrap configuration allows the client to be initialized with a predefined set of toggle states. Bootstrapping can be configured by providing a bootstrap configuration when initializing the client.
380
+ Bootstrap configuration allows the client to be initialized with a predefined set of toggle states.
381
+ Bootstrapping can be configured by providing a bootstrap configuration when initializing the client.
279
382
  ```ruby
280
383
  @unleash = Unleash::Client.new(
281
- url: 'http://unleash.herokuapp.com/api',
384
+ url: 'https://unleash.herokuapp.com/api',
282
385
  app_name: 'my_ruby_app',
283
386
  custom_http_headers: { 'Authorization': '<API token>' },
284
387
  bootstrap_config: Unleash::Bootstrap::Configuration.new({
285
- url: "http://unleash.herokuapp.com/api/client/features",
388
+ url: "https://unleash.herokuapp.com/api/client/features",
286
389
  url_headers: {'Authorization': '<API token>'}
287
390
  })
288
391
  )
@@ -295,7 +398,8 @@ The `Bootstrap::Configuration` initializer takes a hash with one of the followin
295
398
  * `data` - A raw JSON string as returned by the Unleash server.
296
399
  * `block` - A lambda containing custom logic if you need it, an example is provided below.
297
400
 
298
- You should only specify one type of bootstrapping since only one will be invoked and the others will be ignored. The order of preference is as follows:
401
+ You should only specify one type of bootstrapping since only one will be invoked and the others will be ignored.
402
+ The order of preference is as follows:
299
403
 
300
404
  - Select a data bootstrapper if it exists.
301
405
  - If no data bootstrapper exists, select the block bootstrapper.
@@ -307,7 +411,7 @@ Example usage:
307
411
 
308
412
  First saving the toggles locally:
309
413
  ```shell
310
- curl -H 'Authorization: <API token>' -XGET 'http://unleash.herokuapp.com/api' > ./default-toggles.json
414
+ curl -H 'Authorization: <API token>' -XGET 'https://unleash.herokuapp.com/api' > ./default-toggles.json
311
415
  ```
312
416
 
313
417
  Now using them on start up:
@@ -320,15 +424,16 @@ custom_boostrapper = lambda {
320
424
 
321
425
  @unleash = Unleash::Client.new(
322
426
  app_name: 'my_ruby_app',
323
- url: 'http://unleash.herokuapp.com/api',
427
+ url: 'https://unleash.herokuapp.com/api',
324
428
  custom_http_headers: { 'Authorization': '<API token>' },
325
429
  bootstrap_config: Unleash::Bootstrap::Configuration.new({
326
430
  block: custom_boostrapper
327
- }
431
+ })
328
432
  )
329
433
  ```
330
434
 
331
- This example could be easily achieved with a file bootstrapper, this is just to illustrate the usage of custom bootstrapping. Be aware that the client initializer will block until bootstrapping is complete.
435
+ This example could be easily achieved with a file bootstrapper, this is just to illustrate the usage of custom bootstrapping.
436
+ Be aware that the client initializer will block until bootstrapping is complete.
332
437
 
333
438
  #### Client methods
334
439
 
@@ -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',
@@ -47,7 +47,7 @@ module Unleash
47
47
 
48
48
  toggle = Unleash::FeatureToggle.new(toggle_as_hash)
49
49
 
50
- toggle.is_enabled?(context, default_value)
50
+ toggle.is_enabled?(context)
51
51
  end
52
52
 
53
53
  # enabled? is a more ruby idiomatic method name than is_enabled?
@@ -23,7 +23,7 @@ module Unleash
23
23
  :bootstrap_config
24
24
 
25
25
  def initialize(opts = {})
26
- ensure_valid_opts(opts)
26
+ validate_custom_http_headers!(opts[:custom_http_headers]) if opts.has_key?(:custom_http_headers)
27
27
  set_defaults
28
28
 
29
29
  initialize_default_logger if opts[:logger].nil?
@@ -40,7 +40,8 @@ module Unleash
40
40
  return if self.disable_client
41
41
 
42
42
  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)
43
+
44
+ validate_custom_http_headers!(self.custom_http_headers)
44
45
  end
45
46
 
46
47
  def refresh_backup_file!
@@ -51,7 +52,7 @@ module Unleash
51
52
  {
52
53
  'UNLEASH-INSTANCEID' => self.instance_id,
53
54
  'UNLEASH-APPNAME' => self.app_name
54
- }.merge(custom_http_headers.dup)
55
+ }.merge!(generate_custom_http_headers)
55
56
  end
56
57
 
57
58
  def fetch_toggles_uri
@@ -78,12 +79,6 @@ module Unleash
78
79
 
79
80
  private
80
81
 
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
82
  def set_defaults
88
83
  self.app_name = nil
89
84
  self.environment = 'default'
@@ -95,7 +90,7 @@ module Unleash
95
90
  self.refresh_interval = 10
96
91
  self.metrics_interval = 60
97
92
  self.timeout = 30
98
- self.retry_limit = 5
93
+ self.retry_limit = Float::INFINITY
99
94
  self.backup_file = nil
100
95
  self.log_level = Logger::WARN
101
96
  self.bootstrap_config = nil
@@ -118,6 +113,18 @@ module Unleash
118
113
  self
119
114
  end
120
115
 
116
+ def validate_custom_http_headers!(custom_http_headers)
117
+ return if custom_http_headers.is_a?(Hash) || custom_http_headers.respond_to?(:call)
118
+
119
+ raise ArgumentError, "custom_http_headers must be a Hash or a Proc."
120
+ end
121
+
122
+ def generate_custom_http_headers
123
+ return self.custom_http_headers.call if self.custom_http_headers.respond_to?(:call)
124
+
125
+ self.custom_http_headers
126
+ end
127
+
121
128
  def set_option(opt, val)
122
129
  __send__("#{opt}=", val)
123
130
  rescue NoMethodError
@@ -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
  )
@@ -65,24 +65,20 @@ module Unleash
65
65
 
66
66
  def save!
67
67
  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
68
 
72
- self.toggle_lock.synchronize do
73
- file = File.open(backup_file_tmp, "w")
69
+ backup_file = Unleash.configuration.backup_file
70
+ backup_file_tmp = "#{backup_file}.tmp"
71
+
72
+ self.toggle_lock.synchronize do
73
+ File.open(backup_file_tmp, "w") do |file|
74
74
  file.write(self.toggle_cache.to_json)
75
- file.close
76
- File.rename(backup_file_tmp, backup_file)
77
75
  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?
76
+ File.rename(backup_file_tmp, backup_file)
85
77
  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}"
86
82
  end
87
83
 
88
84
  private
@@ -110,22 +106,15 @@ module Unleash
110
106
  backup_file = Unleash.configuration.backup_file
111
107
  return nil unless File.exist?(backup_file)
112
108
 
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
109
+ backup_as_hash = JSON.parse(File.read(backup_file))
110
+ synchronize_with_local_cache!(backup_as_hash)
111
+ update_running_client!
112
+ rescue IOError => e
113
+ Unleash.logger.error "Unable to read the backup_file: #{e}"
114
+ rescue JSON::ParserError => e
115
+ Unleash.logger.error "Unable to parse JSON from existing backup_file: #{e}"
116
+ rescue StandardError => e
117
+ Unleash.logger.error "Unable to extract valid data from backup_file. Exception thrown: #{e}"
129
118
  end
130
119
 
131
120
  def bootstrap
@@ -1,3 +1,3 @@
1
1
  module Unleash
2
- VERSION = "4.1.0".freeze
2
+ VERSION = "4.3.0".freeze
3
3
  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.0.0"
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.1.0
4
+ version: 4.3.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-02-11 00:00:00.000000000 Z
11
+ date: 2022-07-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: murmurhash3
@@ -98,16 +98,16 @@ dependencies:
98
98
  name: rubocop
99
99
  requirement: !ruby/object:Gem::Requirement
100
100
  requirements:
101
- - - "~>"
101
+ - - "<"
102
102
  - !ruby/object:Gem::Version
103
- version: '0.80'
103
+ version: 1.0.0
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.0.0
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"