launchdarkly-server-sdk 5.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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