launchdarkly-server-sdk 5.5.7

Sign up to get free protection for your applications and to get access to all the features.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +134 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +37 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  5. data/.gitignore +15 -0
  6. data/.hound.yml +2 -0
  7. data/.rspec +2 -0
  8. data/.rubocop.yml +600 -0
  9. data/.simplecov +4 -0
  10. data/.yardopts +9 -0
  11. data/CHANGELOG.md +261 -0
  12. data/CODEOWNERS +1 -0
  13. data/CONTRIBUTING.md +37 -0
  14. data/Gemfile +3 -0
  15. data/Gemfile.lock +102 -0
  16. data/LICENSE.txt +13 -0
  17. data/README.md +56 -0
  18. data/Rakefile +5 -0
  19. data/azure-pipelines.yml +51 -0
  20. data/ext/mkrf_conf.rb +11 -0
  21. data/launchdarkly-server-sdk.gemspec +40 -0
  22. data/lib/ldclient-rb.rb +29 -0
  23. data/lib/ldclient-rb/cache_store.rb +45 -0
  24. data/lib/ldclient-rb/config.rb +411 -0
  25. data/lib/ldclient-rb/evaluation.rb +455 -0
  26. data/lib/ldclient-rb/event_summarizer.rb +55 -0
  27. data/lib/ldclient-rb/events.rb +468 -0
  28. data/lib/ldclient-rb/expiring_cache.rb +77 -0
  29. data/lib/ldclient-rb/file_data_source.rb +312 -0
  30. data/lib/ldclient-rb/flags_state.rb +76 -0
  31. data/lib/ldclient-rb/impl.rb +13 -0
  32. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +158 -0
  33. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +228 -0
  34. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +155 -0
  35. data/lib/ldclient-rb/impl/store_client_wrapper.rb +47 -0
  36. data/lib/ldclient-rb/impl/store_data_set_sorter.rb +55 -0
  37. data/lib/ldclient-rb/in_memory_store.rb +100 -0
  38. data/lib/ldclient-rb/integrations.rb +55 -0
  39. data/lib/ldclient-rb/integrations/consul.rb +38 -0
  40. data/lib/ldclient-rb/integrations/dynamodb.rb +47 -0
  41. data/lib/ldclient-rb/integrations/redis.rb +55 -0
  42. data/lib/ldclient-rb/integrations/util/store_wrapper.rb +230 -0
  43. data/lib/ldclient-rb/interfaces.rb +153 -0
  44. data/lib/ldclient-rb/ldclient.rb +424 -0
  45. data/lib/ldclient-rb/memoized_value.rb +32 -0
  46. data/lib/ldclient-rb/newrelic.rb +17 -0
  47. data/lib/ldclient-rb/non_blocking_thread_pool.rb +46 -0
  48. data/lib/ldclient-rb/polling.rb +78 -0
  49. data/lib/ldclient-rb/redis_store.rb +87 -0
  50. data/lib/ldclient-rb/requestor.rb +101 -0
  51. data/lib/ldclient-rb/simple_lru_cache.rb +25 -0
  52. data/lib/ldclient-rb/stream.rb +141 -0
  53. data/lib/ldclient-rb/user_filter.rb +51 -0
  54. data/lib/ldclient-rb/util.rb +50 -0
  55. data/lib/ldclient-rb/version.rb +3 -0
  56. data/scripts/gendocs.sh +11 -0
  57. data/scripts/release.sh +27 -0
  58. data/spec/config_spec.rb +63 -0
  59. data/spec/evaluation_spec.rb +739 -0
  60. data/spec/event_summarizer_spec.rb +63 -0
  61. data/spec/events_spec.rb +642 -0
  62. data/spec/expiring_cache_spec.rb +76 -0
  63. data/spec/feature_store_spec_base.rb +213 -0
  64. data/spec/file_data_source_spec.rb +255 -0
  65. data/spec/fixtures/feature.json +37 -0
  66. data/spec/fixtures/feature1.json +36 -0
  67. data/spec/fixtures/user.json +9 -0
  68. data/spec/flags_state_spec.rb +81 -0
  69. data/spec/http_util.rb +109 -0
  70. data/spec/in_memory_feature_store_spec.rb +12 -0
  71. data/spec/integrations/consul_feature_store_spec.rb +42 -0
  72. data/spec/integrations/dynamodb_feature_store_spec.rb +105 -0
  73. data/spec/integrations/store_wrapper_spec.rb +276 -0
  74. data/spec/ldclient_spec.rb +471 -0
  75. data/spec/newrelic_spec.rb +5 -0
  76. data/spec/polling_spec.rb +120 -0
  77. data/spec/redis_feature_store_spec.rb +95 -0
  78. data/spec/requestor_spec.rb +214 -0
  79. data/spec/segment_store_spec_base.rb +95 -0
  80. data/spec/simple_lru_cache_spec.rb +24 -0
  81. data/spec/spec_helper.rb +9 -0
  82. data/spec/store_spec.rb +10 -0
  83. data/spec/stream_spec.rb +60 -0
  84. data/spec/user_filter_spec.rb +91 -0
  85. data/spec/util_spec.rb +17 -0
  86. data/spec/version_spec.rb +7 -0
  87. metadata +375 -0
@@ -0,0 +1,51 @@
1
+ require "json"
2
+ require "set"
3
+
4
+ module LaunchDarkly
5
+ # @private
6
+ class UserFilter
7
+ def initialize(config)
8
+ @all_attributes_private = config.all_attributes_private
9
+ @private_attribute_names = Set.new(config.private_attribute_names.map(&:to_sym))
10
+ end
11
+
12
+ def transform_user_props(user_props)
13
+ return nil if user_props.nil?
14
+
15
+ user_private_attrs = Set.new((user_props[:privateAttributeNames] || []).map(&:to_sym))
16
+
17
+ filtered_user_props, removed = filter_values(user_props, user_private_attrs, ALLOWED_TOP_LEVEL_KEYS, IGNORED_TOP_LEVEL_KEYS)
18
+ if user_props.has_key?(:custom)
19
+ filtered_user_props[:custom], removed_custom = filter_values(user_props[:custom], user_private_attrs)
20
+ removed.merge(removed_custom)
21
+ end
22
+
23
+ unless removed.empty?
24
+ # note, :privateAttributeNames is what the developer sets; :privateAttrs is what we send to the server
25
+ filtered_user_props[:privateAttrs] = removed.to_a.sort.map { |s| s.to_s }
26
+ end
27
+ return filtered_user_props
28
+ end
29
+
30
+ private
31
+
32
+ ALLOWED_TOP_LEVEL_KEYS = Set.new([:key, :secondary, :ip, :country, :email,
33
+ :firstName, :lastName, :avatar, :name, :anonymous, :custom])
34
+ IGNORED_TOP_LEVEL_KEYS = Set.new([:custom, :key, :anonymous])
35
+
36
+ def filter_values(props, user_private_attrs, allowed_keys = [], keys_to_leave_as_is = [])
37
+ is_valid_key = lambda { |key| allowed_keys.empty? || allowed_keys.include?(key) }
38
+ removed_keys = Set.new(props.keys.select { |key|
39
+ # Note that if is_valid_key returns false, we don't explicitly *remove* the key (which would place
40
+ # it in the privateAttrs list) - we just silently drop it when we calculate filtered_hash.
41
+ is_valid_key.call(key) && !keys_to_leave_as_is.include?(key) && private_attr?(key, user_private_attrs)
42
+ })
43
+ filtered_hash = props.select { |key, value| !removed_keys.include?(key) && is_valid_key.call(key) }
44
+ [filtered_hash, removed_keys]
45
+ end
46
+
47
+ def private_attr?(name, user_private_attrs)
48
+ @all_attributes_private || @private_attribute_names.include?(name) || user_private_attrs.include?(name)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,50 @@
1
+ require "net/http"
2
+ require "uri"
3
+
4
+ module LaunchDarkly
5
+ # @private
6
+ module Util
7
+ def self.stringify_attrs(hash, attrs)
8
+ return hash if hash.nil?
9
+ ret = hash
10
+ changed = false
11
+ attrs.each do |attr|
12
+ value = hash[attr]
13
+ if !value.nil? && !value.is_a?(String)
14
+ ret = hash.clone if !changed
15
+ ret[attr] = value.to_s
16
+ changed = true
17
+ end
18
+ end
19
+ ret
20
+ end
21
+
22
+ def self.new_http_client(uri_s, config)
23
+ uri = URI(uri_s)
24
+ client = Net::HTTP.new(uri.hostname, uri.port)
25
+ client.use_ssl = true if uri.scheme == "https"
26
+ client.open_timeout = config.connect_timeout
27
+ client.read_timeout = config.read_timeout
28
+ client
29
+ end
30
+
31
+ def self.log_exception(logger, message, exc)
32
+ logger.error { "[LDClient] #{message}: #{exc.inspect}" }
33
+ logger.debug { "[LDClient] Exception trace: #{exc.backtrace}" }
34
+ end
35
+
36
+ def self.http_error_recoverable?(status)
37
+ if status >= 400 && status < 500
38
+ status == 400 || status == 408 || status == 429
39
+ else
40
+ true
41
+ end
42
+ end
43
+
44
+ def self.http_error_message(status, context, recoverable_message)
45
+ desc = (status == 401 || status == 403) ? " (invalid SDK key)" : ""
46
+ message = Util.http_error_recoverable?(status) ? recoverable_message : "giving up permanently"
47
+ "HTTP error #{status}#{desc} for #{context} - #{message}"
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,3 @@
1
+ module LaunchDarkly
2
+ VERSION = "5.5.7"
3
+ end
@@ -0,0 +1,11 @@
1
+ #!/bin/bash
2
+
3
+ # Use this script to generate documentation locally in ./doc so it can be proofed before release.
4
+ # After release, documentation will be visible at https://www.rubydoc.info/gems/launchdarkly-server-sdk
5
+
6
+ gem install --conservative yard
7
+ gem install --conservative redcarpet # provides Markdown formatting
8
+
9
+ rm -rf doc/*
10
+
11
+ yard doc
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # This script updates the version for the launchdarkly-server-sdk library and releases it to RubyGems
3
+ # It will only work if you have the proper credentials set up in ~/.gem/credentials
4
+
5
+ # It takes exactly one argument: the new version.
6
+ # It should be run from the root of this git repo like this:
7
+ # ./scripts/release.sh 4.0.9
8
+
9
+ # When done you should commit and push the changes made.
10
+
11
+ set -uxe
12
+ echo "Starting ruby-server-sdk release."
13
+
14
+ VERSION=$1
15
+
16
+ #Update version in lib/ldclient-rb/version.rb
17
+ VERSION_RB_TEMP=./version.rb.tmp
18
+ sed "s/VERSION =.*/VERSION = \"${VERSION}\"/g" lib/ldclient-rb/version.rb > ${VERSION_RB_TEMP}
19
+ mv ${VERSION_RB_TEMP} lib/ldclient-rb/version.rb
20
+
21
+ # Build Ruby Gem
22
+ gem build launchdarkly-server-sdk.gemspec
23
+
24
+ # Publish Ruby Gem
25
+ gem push launchdarkly-server-sdk-${VERSION}.gem
26
+
27
+ echo "Done with ruby-server-sdk release"
@@ -0,0 +1,63 @@
1
+ require "spec_helper"
2
+
3
+ describe LaunchDarkly::Config do
4
+ subject { LaunchDarkly::Config }
5
+ describe ".initialize" do
6
+ it "can be initialized with default settings" do
7
+ expect(subject).to receive(:default_capacity).and_return 1234
8
+ expect(subject.new.capacity).to eq 1234
9
+ end
10
+ it "accepts custom arguments" do
11
+ expect(subject).to_not receive(:default_capacity)
12
+ expect(subject.new(capacity: 50).capacity).to eq 50
13
+ end
14
+ it "will chomp base_url and stream_uri" do
15
+ uri = "https://test.launchdarkly.com"
16
+ config = subject.new(base_uri: uri + "/")
17
+ expect(config.base_uri).to eq uri
18
+ end
19
+ end
20
+ describe "@base_uri" do
21
+ it "can be read" do
22
+ expect(subject.new.base_uri).to eq subject.default_base_uri
23
+ end
24
+ end
25
+ describe "@events_uri" do
26
+ it "can be read" do
27
+ expect(subject.new.events_uri).to eq subject.default_events_uri
28
+ end
29
+ end
30
+ describe "@stream_uri" do
31
+ it "can be read" do
32
+ expect(subject.new.stream_uri).to eq subject.default_stream_uri
33
+ end
34
+ end
35
+ describe ".default_cache_store" do
36
+ it "uses Rails cache if it is available" do
37
+ rails = instance_double("Rails", cache: :cache)
38
+ stub_const("Rails", rails)
39
+ expect(subject.default_cache_store).to eq :cache
40
+ end
41
+ it "uses memory store if Rails is not available" do
42
+ expect(subject.default_cache_store).to be_an_instance_of LaunchDarkly::ThreadSafeMemoryStore
43
+ end
44
+ end
45
+ describe ".default_logger" do
46
+ it "uses Rails logger if it is available" do
47
+ rails = instance_double("Rails", logger: :logger)
48
+ stub_const("Rails", rails)
49
+ expect(subject.default_logger).to eq :logger
50
+ end
51
+ it "Uses logger if Rails is not available" do
52
+ expect(subject.default_logger).to be_an_instance_of Logger
53
+ end
54
+ end
55
+ describe ".poll_interval" do
56
+ it "can be set to greater than the default" do
57
+ expect(subject.new(poll_interval: 31).poll_interval).to eq 31
58
+ end
59
+ it "cannot be set to less than the default" do
60
+ expect(subject.new(poll_interval: 29).poll_interval).to eq 30
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,739 @@
1
+ require "spec_helper"
2
+
3
+ describe LaunchDarkly::Evaluation do
4
+ subject { LaunchDarkly::Evaluation }
5
+
6
+ include LaunchDarkly::Evaluation
7
+
8
+ let(:features) { LaunchDarkly::InMemoryFeatureStore.new }
9
+
10
+ let(:user) {
11
+ {
12
+ key: "userkey",
13
+ email: "test@example.com",
14
+ name: "Bob"
15
+ }
16
+ }
17
+
18
+ let(:logger) { LaunchDarkly::Config.default_logger }
19
+
20
+ def boolean_flag_with_rules(rules)
21
+ { key: 'feature', on: true, rules: rules, fallthrough: { variation: 0 }, variations: [ false, true ] }
22
+ end
23
+
24
+ def boolean_flag_with_clauses(clauses)
25
+ boolean_flag_with_rules([{ id: 'ruleid', clauses: clauses, variation: 1 }])
26
+ end
27
+
28
+ describe "evaluate" do
29
+ it "returns off variation if flag is off" do
30
+ flag = {
31
+ key: 'feature',
32
+ on: false,
33
+ offVariation: 1,
34
+ fallthrough: { variation: 0 },
35
+ variations: ['a', 'b', 'c']
36
+ }
37
+ user = { key: 'x' }
38
+ detail = LaunchDarkly::EvaluationDetail.new('b', 1, { kind: 'OFF' })
39
+ result = evaluate(flag, user, features, logger)
40
+ expect(result.detail).to eq(detail)
41
+ expect(result.events).to eq([])
42
+ end
43
+
44
+ it "returns nil if flag is off and off variation is unspecified" do
45
+ flag = {
46
+ key: 'feature',
47
+ on: false,
48
+ fallthrough: { variation: 0 },
49
+ variations: ['a', 'b', 'c']
50
+ }
51
+ user = { key: 'x' }
52
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil, { kind: 'OFF' })
53
+ result = evaluate(flag, user, features, logger)
54
+ expect(result.detail).to eq(detail)
55
+ expect(result.events).to eq([])
56
+ end
57
+
58
+ it "returns an error if off variation is too high" do
59
+ flag = {
60
+ key: 'feature',
61
+ on: false,
62
+ offVariation: 999,
63
+ fallthrough: { variation: 0 },
64
+ variations: ['a', 'b', 'c']
65
+ }
66
+ user = { key: 'x' }
67
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil,
68
+ { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' })
69
+ result = evaluate(flag, user, features, logger)
70
+ expect(result.detail).to eq(detail)
71
+ expect(result.events).to eq([])
72
+ end
73
+
74
+ it "returns an error if off variation is negative" do
75
+ flag = {
76
+ key: 'feature',
77
+ on: false,
78
+ offVariation: -1,
79
+ fallthrough: { variation: 0 },
80
+ variations: ['a', 'b', 'c']
81
+ }
82
+ user = { key: 'x' }
83
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil,
84
+ { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' })
85
+ result = evaluate(flag, user, features, logger)
86
+ expect(result.detail).to eq(detail)
87
+ expect(result.events).to eq([])
88
+ end
89
+
90
+ it "returns off variation if prerequisite is not found" do
91
+ flag = {
92
+ key: 'feature0',
93
+ on: true,
94
+ prerequisites: [{key: 'badfeature', variation: 1}],
95
+ fallthrough: { variation: 0 },
96
+ offVariation: 1,
97
+ variations: ['a', 'b', 'c']
98
+ }
99
+ user = { key: 'x' }
100
+ detail = LaunchDarkly::EvaluationDetail.new('b', 1,
101
+ { kind: 'PREREQUISITE_FAILED', prerequisiteKey: 'badfeature' })
102
+ result = evaluate(flag, user, features, logger)
103
+ expect(result.detail).to eq(detail)
104
+ expect(result.events).to eq([])
105
+ end
106
+
107
+ it "returns off variation and event if prerequisite of a prerequisite is not found" do
108
+ flag = {
109
+ key: 'feature0',
110
+ on: true,
111
+ prerequisites: [{key: 'feature1', variation: 1}],
112
+ fallthrough: { variation: 0 },
113
+ offVariation: 1,
114
+ variations: ['a', 'b', 'c'],
115
+ version: 1
116
+ }
117
+ flag1 = {
118
+ key: 'feature1',
119
+ on: true,
120
+ prerequisites: [{key: 'feature2', variation: 1}], # feature2 doesn't exist
121
+ fallthrough: { variation: 0 },
122
+ variations: ['d', 'e'],
123
+ version: 2
124
+ }
125
+ features.upsert(LaunchDarkly::FEATURES, flag1)
126
+ user = { key: 'x' }
127
+ detail = LaunchDarkly::EvaluationDetail.new('b', 1,
128
+ { kind: 'PREREQUISITE_FAILED', prerequisiteKey: 'feature1' })
129
+ events_should_be = [{
130
+ kind: 'feature', key: 'feature1', user: user, variation: nil, value: nil, version: 2, prereqOf: 'feature0',
131
+ trackEvents: nil, debugEventsUntilDate: nil
132
+ }]
133
+ result = evaluate(flag, user, features, logger)
134
+ expect(result.detail).to eq(detail)
135
+ expect(result.events).to eq(events_should_be)
136
+ end
137
+
138
+ it "returns off variation and event if prerequisite is off" do
139
+ flag = {
140
+ key: 'feature0',
141
+ on: true,
142
+ prerequisites: [{key: 'feature1', variation: 1}],
143
+ fallthrough: { variation: 0 },
144
+ offVariation: 1,
145
+ variations: ['a', 'b', 'c'],
146
+ version: 1
147
+ }
148
+ flag1 = {
149
+ key: 'feature1',
150
+ on: false,
151
+ # note that even though it returns the desired variation, it is still off and therefore not a match
152
+ offVariation: 1,
153
+ fallthrough: { variation: 0 },
154
+ variations: ['d', 'e'],
155
+ version: 2
156
+ }
157
+ features.upsert(LaunchDarkly::FEATURES, flag1)
158
+ user = { key: 'x' }
159
+ detail = LaunchDarkly::EvaluationDetail.new('b', 1,
160
+ { kind: 'PREREQUISITE_FAILED', prerequisiteKey: 'feature1' })
161
+ events_should_be = [{
162
+ kind: 'feature', key: 'feature1', user: user, variation: 1, value: 'e', version: 2, prereqOf: 'feature0',
163
+ trackEvents: nil, debugEventsUntilDate: nil
164
+ }]
165
+ result = evaluate(flag, user, features, logger)
166
+ expect(result.detail).to eq(detail)
167
+ expect(result.events).to eq(events_should_be)
168
+ end
169
+
170
+ it "returns off variation and event if prerequisite is not met" do
171
+ flag = {
172
+ key: 'feature0',
173
+ on: true,
174
+ prerequisites: [{key: 'feature1', variation: 1}],
175
+ fallthrough: { variation: 0 },
176
+ offVariation: 1,
177
+ variations: ['a', 'b', 'c'],
178
+ version: 1
179
+ }
180
+ flag1 = {
181
+ key: 'feature1',
182
+ on: true,
183
+ fallthrough: { variation: 0 },
184
+ variations: ['d', 'e'],
185
+ version: 2
186
+ }
187
+ features.upsert(LaunchDarkly::FEATURES, flag1)
188
+ user = { key: 'x' }
189
+ detail = LaunchDarkly::EvaluationDetail.new('b', 1,
190
+ { kind: 'PREREQUISITE_FAILED', prerequisiteKey: 'feature1' })
191
+ events_should_be = [{
192
+ kind: 'feature', key: 'feature1', user: user, variation: 0, value: 'd', version: 2, prereqOf: 'feature0',
193
+ trackEvents: nil, debugEventsUntilDate: nil
194
+ }]
195
+ result = evaluate(flag, user, features, logger)
196
+ expect(result.detail).to eq(detail)
197
+ expect(result.events).to eq(events_should_be)
198
+ end
199
+
200
+ it "returns fallthrough variation and event if prerequisite is met and there are no rules" do
201
+ flag = {
202
+ key: 'feature0',
203
+ on: true,
204
+ prerequisites: [{key: 'feature1', variation: 1}],
205
+ fallthrough: { variation: 0 },
206
+ offVariation: 1,
207
+ variations: ['a', 'b', 'c'],
208
+ version: 1
209
+ }
210
+ flag1 = {
211
+ key: 'feature1',
212
+ on: true,
213
+ fallthrough: { variation: 1 },
214
+ variations: ['d', 'e'],
215
+ version: 2
216
+ }
217
+ features.upsert(LaunchDarkly::FEATURES, flag1)
218
+ user = { key: 'x' }
219
+ detail = LaunchDarkly::EvaluationDetail.new('a', 0, { kind: 'FALLTHROUGH' })
220
+ events_should_be = [{
221
+ kind: 'feature', key: 'feature1', user: user, variation: 1, value: 'e', version: 2, prereqOf: 'feature0',
222
+ trackEvents: nil, debugEventsUntilDate: nil
223
+ }]
224
+ result = evaluate(flag, user, features, logger)
225
+ expect(result.detail).to eq(detail)
226
+ expect(result.events).to eq(events_should_be)
227
+ end
228
+
229
+ it "returns an error if fallthrough variation is too high" do
230
+ flag = {
231
+ key: 'feature',
232
+ on: true,
233
+ fallthrough: { variation: 999 },
234
+ offVariation: 1,
235
+ variations: ['a', 'b', 'c']
236
+ }
237
+ user = { key: 'userkey' }
238
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil, { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' })
239
+ result = evaluate(flag, user, features, logger)
240
+ expect(result.detail).to eq(detail)
241
+ expect(result.events).to eq([])
242
+ end
243
+
244
+ it "returns an error if fallthrough variation is negative" do
245
+ flag = {
246
+ key: 'feature',
247
+ on: true,
248
+ fallthrough: { variation: -1 },
249
+ offVariation: 1,
250
+ variations: ['a', 'b', 'c']
251
+ }
252
+ user = { key: 'userkey' }
253
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil, { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' })
254
+ result = evaluate(flag, user, features, logger)
255
+ expect(result.detail).to eq(detail)
256
+ expect(result.events).to eq([])
257
+ end
258
+
259
+ it "returns an error if fallthrough has no variation or rollout" do
260
+ flag = {
261
+ key: 'feature',
262
+ on: true,
263
+ fallthrough: { },
264
+ offVariation: 1,
265
+ variations: ['a', 'b', 'c']
266
+ }
267
+ user = { key: 'userkey' }
268
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil, { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' })
269
+ result = evaluate(flag, user, features, logger)
270
+ expect(result.detail).to eq(detail)
271
+ expect(result.events).to eq([])
272
+ end
273
+
274
+ it "returns an error if fallthrough has a rollout with no variations" do
275
+ flag = {
276
+ key: 'feature',
277
+ on: true,
278
+ fallthrough: { rollout: { variations: [] } },
279
+ offVariation: 1,
280
+ variations: ['a', 'b', 'c']
281
+ }
282
+ user = { key: 'userkey' }
283
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil, { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' })
284
+ result = evaluate(flag, user, features, logger)
285
+ expect(result.detail).to eq(detail)
286
+ expect(result.events).to eq([])
287
+ end
288
+
289
+ it "matches user from targets" do
290
+ flag = {
291
+ key: 'feature',
292
+ on: true,
293
+ targets: [
294
+ { values: [ 'whoever', 'userkey' ], variation: 2 }
295
+ ],
296
+ fallthrough: { variation: 0 },
297
+ offVariation: 1,
298
+ variations: ['a', 'b', 'c']
299
+ }
300
+ user = { key: 'userkey' }
301
+ detail = LaunchDarkly::EvaluationDetail.new('c', 2, { kind: 'TARGET_MATCH' })
302
+ result = evaluate(flag, user, features, logger)
303
+ expect(result.detail).to eq(detail)
304
+ expect(result.events).to eq([])
305
+ end
306
+
307
+ it "matches user from rules" do
308
+ rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: 1 }
309
+ flag = boolean_flag_with_rules([rule])
310
+ user = { key: 'userkey' }
311
+ detail = LaunchDarkly::EvaluationDetail.new(true, 1,
312
+ { kind: 'RULE_MATCH', ruleIndex: 0, ruleId: 'ruleid' })
313
+ result = evaluate(flag, user, features, logger)
314
+ expect(result.detail).to eq(detail)
315
+ expect(result.events).to eq([])
316
+ end
317
+
318
+ it "returns an error if rule variation is too high" do
319
+ rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: 999 }
320
+ flag = boolean_flag_with_rules([rule])
321
+ user = { key: 'userkey' }
322
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil,
323
+ { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' })
324
+ result = evaluate(flag, user, features, logger)
325
+ expect(result.detail).to eq(detail)
326
+ expect(result.events).to eq([])
327
+ end
328
+
329
+ it "returns an error if rule variation is negative" do
330
+ rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }], variation: -1 }
331
+ flag = boolean_flag_with_rules([rule])
332
+ user = { key: 'userkey' }
333
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil,
334
+ { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' })
335
+ result = evaluate(flag, user, features, logger)
336
+ expect(result.detail).to eq(detail)
337
+ expect(result.events).to eq([])
338
+ end
339
+
340
+ it "returns an error if rule has neither variation nor rollout" do
341
+ rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }] }
342
+ flag = boolean_flag_with_rules([rule])
343
+ user = { key: 'userkey' }
344
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil,
345
+ { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' })
346
+ result = evaluate(flag, user, features, logger)
347
+ expect(result.detail).to eq(detail)
348
+ expect(result.events).to eq([])
349
+ end
350
+
351
+ it "returns an error if rule has a rollout with no variations" do
352
+ rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }],
353
+ rollout: { variations: [] } }
354
+ flag = boolean_flag_with_rules([rule])
355
+ user = { key: 'userkey' }
356
+ detail = LaunchDarkly::EvaluationDetail.new(nil, nil,
357
+ { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' })
358
+ result = evaluate(flag, user, features, logger)
359
+ expect(result.detail).to eq(detail)
360
+ expect(result.events).to eq([])
361
+ end
362
+
363
+ it "coerces user key to a string for evaluation" do
364
+ clause = { attribute: 'key', op: 'in', values: ['999'] }
365
+ flag = boolean_flag_with_clauses([clause])
366
+ user = { key: 999 }
367
+ result = evaluate(flag, user, features, logger)
368
+ expect(result.detail.value).to eq(true)
369
+ end
370
+
371
+ it "coerces secondary key to a string for evaluation" do
372
+ # We can't really verify that the rollout calculation works correctly, but we can at least
373
+ # make sure it doesn't error out if there's a non-string secondary value (ch35189)
374
+ rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }],
375
+ rollout: { salt: '', variations: [ { weight: 100000, variation: 1 } ] } }
376
+ flag = boolean_flag_with_rules([rule])
377
+ user = { key: "userkey", secondary: 999 }
378
+ result = evaluate(flag, user, features, logger)
379
+ expect(result.detail.reason).to eq({ kind: 'RULE_MATCH', ruleIndex: 0, ruleId: 'ruleid'})
380
+ end
381
+ end
382
+
383
+ describe "clause" do
384
+ it "can match built-in attribute" do
385
+ user = { key: 'x', name: 'Bob' }
386
+ clause = { attribute: 'name', op: 'in', values: ['Bob'] }
387
+ flag = boolean_flag_with_clauses([clause])
388
+ expect(evaluate(flag, user, features, logger).detail.value).to be true
389
+ end
390
+
391
+ it "can match custom attribute" do
392
+ user = { key: 'x', name: 'Bob', custom: { legs: 4 } }
393
+ clause = { attribute: 'legs', op: 'in', values: [4] }
394
+ flag = boolean_flag_with_clauses([clause])
395
+ expect(evaluate(flag, user, features, logger).detail.value).to be true
396
+ end
397
+
398
+ it "returns false for missing attribute" do
399
+ user = { key: 'x', name: 'Bob' }
400
+ clause = { attribute: 'legs', op: 'in', values: [4] }
401
+ flag = boolean_flag_with_clauses([clause])
402
+ expect(evaluate(flag, user, features, logger).detail.value).to be false
403
+ end
404
+
405
+ it "returns false for unknown operator" do
406
+ user = { key: 'x', name: 'Bob' }
407
+ clause = { attribute: 'name', op: 'unknown', values: [4] }
408
+ flag = boolean_flag_with_clauses([clause])
409
+ expect(evaluate(flag, user, features, logger).detail.value).to be false
410
+ end
411
+
412
+ it "does not stop evaluating rules after clause with unknown operator" do
413
+ user = { key: 'x', name: 'Bob' }
414
+ clause0 = { attribute: 'name', op: 'unknown', values: [4] }
415
+ rule0 = { clauses: [ clause0 ], variation: 1 }
416
+ clause1 = { attribute: 'name', op: 'in', values: ['Bob'] }
417
+ rule1 = { clauses: [ clause1 ], variation: 1 }
418
+ flag = boolean_flag_with_rules([rule0, rule1])
419
+ expect(evaluate(flag, user, features, logger).detail.value).to be true
420
+ end
421
+
422
+ it "can be negated" do
423
+ user = { key: 'x', name: 'Bob' }
424
+ clause = { attribute: 'name', op: 'in', values: ['Bob'], negate: true }
425
+ flag = boolean_flag_with_clauses([clause])
426
+ expect(evaluate(flag, user, features, logger).detail.value).to be false
427
+ end
428
+
429
+ it "retrieves segment from segment store for segmentMatch operator" do
430
+ segment = {
431
+ key: 'segkey',
432
+ included: [ 'userkey' ],
433
+ version: 1,
434
+ deleted: false
435
+ }
436
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
437
+
438
+ user = { key: 'userkey' }
439
+ clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] }
440
+ flag = boolean_flag_with_clauses([clause])
441
+ expect(evaluate(flag, user, features, logger).detail.value).to be true
442
+ end
443
+
444
+ it "falls through with no errors if referenced segment is not found" do
445
+ user = { key: 'userkey' }
446
+ clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] }
447
+ flag = boolean_flag_with_clauses([clause])
448
+ expect(evaluate(flag, user, features, logger).detail.value).to be false
449
+ end
450
+
451
+ it "can be negated" do
452
+ user = { key: 'x', name: 'Bob' }
453
+ clause = { attribute: 'name', op: 'in', values: ['Bob'] }
454
+ flag = boolean_flag_with_clauses([clause])
455
+ expect {
456
+ clause[:negate] = true
457
+ }.to change {evaluate(flag, user, features, logger).detail.value}.from(true).to(false)
458
+ end
459
+ end
460
+
461
+ describe "operators" do
462
+ dateStr1 = "2017-12-06T00:00:00.000-07:00"
463
+ dateStr2 = "2017-12-06T00:01:01.000-07:00"
464
+ dateMs1 = 10000000
465
+ dateMs2 = 10000001
466
+ invalidDate = "hey what's this?"
467
+
468
+ operatorTests = [
469
+ # numeric comparisons
470
+ [ :in, 99, 99, true ],
471
+ [ :in, 99.0001, 99.0001, true ],
472
+ [ :in, 99, 99.0001, false ],
473
+ [ :in, 99.0001, 99, false ],
474
+ [ :lessThan, 99, 99.0001, true ],
475
+ [ :lessThan, 99.0001, 99, false ],
476
+ [ :lessThan, 99, 99, false ],
477
+ [ :lessThanOrEqual, 99, 99.0001, true ],
478
+ [ :lessThanOrEqual, 99.0001, 99, false ],
479
+ [ :lessThanOrEqual, 99, 99, true ],
480
+ [ :greaterThan, 99.0001, 99, true ],
481
+ [ :greaterThan, 99, 99.0001, false ],
482
+ [ :greaterThan, 99, 99, false ],
483
+ [ :greaterThanOrEqual, 99.0001, 99, true ],
484
+ [ :greaterThanOrEqual, 99, 99.0001, false ],
485
+ [ :greaterThanOrEqual, 99, 99, true ],
486
+
487
+ # string comparisons
488
+ [ :in, "x", "x", true ],
489
+ [ :in, "x", "xyz", false ],
490
+ [ :startsWith, "xyz", "x", true ],
491
+ [ :startsWith, "x", "xyz", false ],
492
+ [ :endsWith, "xyz", "z", true ],
493
+ [ :endsWith, "z", "xyz", false ],
494
+ [ :contains, "xyz", "y", true ],
495
+ [ :contains, "y", "xyz", false ],
496
+
497
+ # mixed strings and numbers
498
+ [ :in, "99", 99, false ],
499
+ [ :in, 99, "99", false ],
500
+ #[ :contains, "99", 99, false ], # currently throws exception - would return false in Java SDK
501
+ #[ :startsWith, "99", 99, false ], # currently throws exception - would return false in Java SDK
502
+ #[ :endsWith, "99", 99, false ] # currently throws exception - would return false in Java SDK
503
+ [ :lessThanOrEqual, "99", 99, false ],
504
+ #[ :lessThanOrEqual, 99, "99", false ], # currently throws exception - would return false in Java SDK
505
+ [ :greaterThanOrEqual, "99", 99, false ],
506
+ #[ :greaterThanOrEqual, 99, "99", false ], # currently throws exception - would return false in Java SDK
507
+
508
+ # regex
509
+ [ :matches, "hello world", "hello.*rld", true ],
510
+ [ :matches, "hello world", "hello.*orl", true ],
511
+ [ :matches, "hello world", "l+", true ],
512
+ [ :matches, "hello world", "(world|planet)", true ],
513
+ [ :matches, "hello world", "aloha", false ],
514
+ #[ :matches, "hello world", "***not a regex", false ] # currently throws exception - same as Java SDK
515
+
516
+ # dates
517
+ [ :before, dateStr1, dateStr2, true ],
518
+ [ :before, dateMs1, dateMs2, true ],
519
+ [ :before, dateStr2, dateStr1, false ],
520
+ [ :before, dateMs2, dateMs1, false ],
521
+ [ :before, dateStr1, dateStr1, false ],
522
+ [ :before, dateMs1, dateMs1, false ],
523
+ [ :before, dateStr1, invalidDate, false ],
524
+ [ :after, dateStr1, dateStr2, false ],
525
+ [ :after, dateMs1, dateMs2, false ],
526
+ [ :after, dateStr2, dateStr1, true ],
527
+ [ :after, dateMs2, dateMs1, true ],
528
+ [ :after, dateStr1, dateStr1, false ],
529
+ [ :after, dateMs1, dateMs1, false ],
530
+ [ :after, dateStr1, invalidDate, false ],
531
+
532
+ # semver
533
+ [ :semVerEqual, "2.0.1", "2.0.1", true ],
534
+ [ :semVerEqual, "2.0", "2.0.0", true ],
535
+ [ :semVerEqual, "2-rc1", "2.0.0-rc1", true ],
536
+ [ :semVerEqual, "2+build2", "2.0.0+build2", true ],
537
+ [ :semVerLessThan, "2.0.0", "2.0.1", true ],
538
+ [ :semVerLessThan, "2.0", "2.0.1", true ],
539
+ [ :semVerLessThan, "2.0.1", "2.0.0", false ],
540
+ [ :semVerLessThan, "2.0.1", "2.0", false ],
541
+ [ :semVerLessThan, "2.0.0-rc", "2.0.0-rc.beta", true ],
542
+ [ :semVerGreaterThan, "2.0.1", "2.0.0", true ],
543
+ [ :semVerGreaterThan, "2.0.1", "2.0", true ],
544
+ [ :semVerGreaterThan, "2.0.0", "2.0.1", false ],
545
+ [ :semVerGreaterThan, "2.0", "2.0.1", false ],
546
+ [ :semVerGreaterThan, "2.0.0-rc.1", "2.0.0-rc.0", true ],
547
+ [ :semVerLessThan, "2.0.1", "xbad%ver", false ],
548
+ [ :semVerGreaterThan, "2.0.1", "xbad%ver", false ]
549
+ ]
550
+
551
+ operatorTests.each do |params|
552
+ op = params[0]
553
+ value1 = params[1]
554
+ value2 = params[2]
555
+ shouldBe = params[3]
556
+ it "should return #{shouldBe} for #{value1} #{op} #{value2}" do
557
+ user = { key: 'x', custom: { foo: value1 } }
558
+ clause = { attribute: 'foo', op: op, values: [value2] }
559
+ flag = boolean_flag_with_clauses([clause])
560
+ expect(evaluate(flag, user, features, logger).detail.value).to be shouldBe
561
+ end
562
+ end
563
+ end
564
+
565
+ describe "bucket_user" do
566
+ it "gets expected bucket values for specific keys" do
567
+ user = { key: "userKeyA" }
568
+ bucket = bucket_user(user, "hashKey", "key", "saltyA")
569
+ expect(bucket).to be_within(0.0000001).of(0.42157587);
570
+
571
+ user = { key: "userKeyB" }
572
+ bucket = bucket_user(user, "hashKey", "key", "saltyA")
573
+ expect(bucket).to be_within(0.0000001).of(0.6708485);
574
+
575
+ user = { key: "userKeyC" }
576
+ bucket = bucket_user(user, "hashKey", "key", "saltyA")
577
+ expect(bucket).to be_within(0.0000001).of(0.10343106);
578
+ end
579
+
580
+ it "can bucket by int value (equivalent to string)" do
581
+ user = {
582
+ key: "userkey",
583
+ custom: {
584
+ stringAttr: "33333",
585
+ intAttr: 33333
586
+ }
587
+ }
588
+ stringResult = bucket_user(user, "hashKey", "stringAttr", "saltyA")
589
+ intResult = bucket_user(user, "hashKey", "intAttr", "saltyA")
590
+
591
+ expect(intResult).to be_within(0.0000001).of(0.54771423)
592
+ expect(intResult).to eq(stringResult)
593
+ end
594
+
595
+ it "cannot bucket by float value" do
596
+ user = {
597
+ key: "userkey",
598
+ custom: {
599
+ floatAttr: 33.5
600
+ }
601
+ }
602
+ result = bucket_user(user, "hashKey", "floatAttr", "saltyA")
603
+ expect(result).to eq(0.0)
604
+ end
605
+
606
+
607
+ it "cannot bucket by bool value" do
608
+ user = {
609
+ key: "userkey",
610
+ custom: {
611
+ boolAttr: true
612
+ }
613
+ }
614
+ result = bucket_user(user, "hashKey", "boolAttr", "saltyA")
615
+ expect(result).to eq(0.0)
616
+ end
617
+ end
618
+
619
+ def make_segment(key)
620
+ {
621
+ key: key,
622
+ included: [],
623
+ excluded: [],
624
+ salt: 'abcdef',
625
+ version: 1
626
+ }
627
+ end
628
+
629
+ def make_segment_match_clause(segment)
630
+ {
631
+ op: :segmentMatch,
632
+ values: [ segment[:key] ],
633
+ negate: false
634
+ }
635
+ end
636
+
637
+ def make_user_matching_clause(user, attr)
638
+ {
639
+ attribute: attr.to_s,
640
+ op: :in,
641
+ values: [ user[attr.to_sym] ],
642
+ negate: false
643
+ }
644
+ end
645
+
646
+ describe 'segment matching' do
647
+ def test_segment_match(segment)
648
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
649
+ clause = make_segment_match_clause(segment)
650
+ flag = boolean_flag_with_clauses([clause])
651
+ evaluate(flag, user, features, logger).detail.value
652
+ end
653
+
654
+ it 'explicitly includes user' do
655
+ segment = make_segment('segkey')
656
+ segment[:included] = [ user[:key] ]
657
+ expect(test_segment_match(segment)).to be true
658
+ end
659
+
660
+ it 'explicitly excludes user' do
661
+ segment = make_segment('segkey')
662
+ segment[:excluded] = [ user[:key] ]
663
+ expect(test_segment_match(segment)).to be false
664
+ end
665
+
666
+ it 'both includes and excludes user; include takes priority' do
667
+ segment = make_segment('segkey')
668
+ segment[:included] = [ user[:key] ]
669
+ segment[:excluded] = [ user[:key] ]
670
+ expect(test_segment_match(segment)).to be true
671
+ end
672
+
673
+ it 'matches user by rule when weight is absent' do
674
+ segClause = make_user_matching_clause(user, :email)
675
+ segRule = {
676
+ clauses: [ segClause ]
677
+ }
678
+ segment = make_segment('segkey')
679
+ segment[:rules] = [ segRule ]
680
+ expect(test_segment_match(segment)).to be true
681
+ end
682
+
683
+ it 'matches user by rule when weight is nil' do
684
+ segClause = make_user_matching_clause(user, :email)
685
+ segRule = {
686
+ clauses: [ segClause ],
687
+ weight: nil
688
+ }
689
+ segment = make_segment('segkey')
690
+ segment[:rules] = [ segRule ]
691
+ expect(test_segment_match(segment)).to be true
692
+ end
693
+
694
+ it 'matches user with full rollout' do
695
+ segClause = make_user_matching_clause(user, :email)
696
+ segRule = {
697
+ clauses: [ segClause ],
698
+ weight: 100000
699
+ }
700
+ segment = make_segment('segkey')
701
+ segment[:rules] = [ segRule ]
702
+ expect(test_segment_match(segment)).to be true
703
+ end
704
+
705
+ it "doesn't match user with zero rollout" do
706
+ segClause = make_user_matching_clause(user, :email)
707
+ segRule = {
708
+ clauses: [ segClause ],
709
+ weight: 0
710
+ }
711
+ segment = make_segment('segkey')
712
+ segment[:rules] = [ segRule ]
713
+ expect(test_segment_match(segment)).to be false
714
+ end
715
+
716
+ it "matches user with multiple clauses" do
717
+ segClause1 = make_user_matching_clause(user, :email)
718
+ segClause2 = make_user_matching_clause(user, :name)
719
+ segRule = {
720
+ clauses: [ segClause1, segClause2 ]
721
+ }
722
+ segment = make_segment('segkey')
723
+ segment[:rules] = [ segRule ]
724
+ expect(test_segment_match(segment)).to be true
725
+ end
726
+
727
+ it "doesn't match user with multiple clauses if a clause doesn't match" do
728
+ segClause1 = make_user_matching_clause(user, :email)
729
+ segClause2 = make_user_matching_clause(user, :name)
730
+ segClause2[:values] = [ 'wrong' ]
731
+ segRule = {
732
+ clauses: [ segClause1, segClause2 ]
733
+ }
734
+ segment = make_segment('segkey')
735
+ segment[:rules] = [ segRule ]
736
+ expect(test_segment_match(segment)).to be false
737
+ end
738
+ end
739
+ end