launchdarkly-server-sdk 6.2.5 → 6.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,438 @@
1
+ require 'ldclient-rb/util'
2
+
3
+ module LaunchDarkly
4
+ module Integrations
5
+ class TestData
6
+ #
7
+ # A builder for feature flag configurations to be used with {TestData}.
8
+ #
9
+ # @see TestData#flag
10
+ # @see TestData#update
11
+ #
12
+ class FlagBuilder
13
+ attr_reader :key
14
+
15
+ # @private
16
+ def initialize(key)
17
+ @key = key
18
+ @on = true
19
+ @variations = []
20
+ end
21
+
22
+ # @private
23
+ def initialize_copy(other)
24
+ super(other)
25
+ @variations = @variations.clone
26
+ @rules = @rules.nil? ? nil : deep_copy_array(@rules)
27
+ @targets = @targets.nil? ? nil : deep_copy_hash(@targets)
28
+ end
29
+
30
+ #
31
+ # Sets targeting to be on or off for this flag.
32
+ #
33
+ # The effect of this depends on the rest of the flag configuration, just as it does on the
34
+ # real LaunchDarkly dashboard. In the default configuration that you get from calling
35
+ # {TestData#flag} with a new flag key, the flag will return `false`
36
+ # whenever targeting is off, and `true` when targeting is on.
37
+ #
38
+ # @param on [Boolean] true if targeting should be on
39
+ # @return [FlagBuilder] the builder
40
+ #
41
+ def on(on)
42
+ @on = on
43
+ self
44
+ end
45
+
46
+ #
47
+ # Specifies the fallthrough variation. The fallthrough is the value
48
+ # that is returned if targeting is on and the user was not matched by a more specific
49
+ # target or rule.
50
+ #
51
+ # If the flag was previously configured with other variations and the variation specified is a boolean,
52
+ # this also changes it to a boolean flag.
53
+ #
54
+ # @param variation [Boolean, Integer] true or false or the desired fallthrough variation index:
55
+ # 0 for the first, 1 for the second, etc.
56
+ # @return [FlagBuilder] the builder
57
+ #
58
+ def fallthrough_variation(variation)
59
+ if LaunchDarkly::Impl::Util.is_bool variation then
60
+ boolean_flag.fallthrough_variation(variation_for_boolean(variation))
61
+ else
62
+ @fallthrough_variation = variation
63
+ self
64
+ end
65
+ end
66
+
67
+ #
68
+ # Specifies the off variation for a flag. This is the variation that is returned
69
+ # whenever targeting is off.
70
+ #
71
+ # If the flag was previously configured with other variations and the variation specified is a boolean,
72
+ # this also changes it to a boolean flag.
73
+ #
74
+ # @param variation [Boolean, Integer] true or false or the desired off variation index:
75
+ # 0 for the first, 1 for the second, etc.
76
+ # @return [FlagBuilder] the builder
77
+ #
78
+ def off_variation(variation)
79
+ if LaunchDarkly::Impl::Util.is_bool variation then
80
+ boolean_flag.off_variation(variation_for_boolean(variation))
81
+ else
82
+ @off_variation = variation
83
+ self
84
+ end
85
+ end
86
+
87
+ #
88
+ # Changes the allowable variation values for the flag.
89
+ #
90
+ # The value may be of any valid JSON type. For instance, a boolean flag
91
+ # normally has `true, false`; a string-valued flag might have
92
+ # `'red', 'green'`; etc.
93
+ #
94
+ # @example A single variation
95
+ # td.flag('new-flag')
96
+ # .variations(true)
97
+ #
98
+ # @example Multiple variations
99
+ # td.flag('new-flag')
100
+ # .variations('red', 'green', 'blue')
101
+ #
102
+ # @param variations [Array<Object>] the the desired variations
103
+ # @return [FlagBuilder] the builder
104
+ #
105
+ def variations(*variations)
106
+ @variations = variations
107
+ self
108
+ end
109
+
110
+ #
111
+ # Sets the flag to always return the specified variation for all users.
112
+ #
113
+ # The variation is specified, Targeting is switched on, and any existing targets or rules are removed.
114
+ # The fallthrough variation is set to the specified value. The off variation is left unchanged.
115
+ #
116
+ # If the flag was previously configured with other variations and the variation specified is a boolean,
117
+ # this also changes it to a boolean flag.
118
+ #
119
+ # @param variation [Boolean, Integer] true or false or the desired variation index to return:
120
+ # 0 for the first, 1 for the second, etc.
121
+ # @return [FlagBuilder] the builder
122
+ #
123
+ def variation_for_all_users(variation)
124
+ if LaunchDarkly::Impl::Util.is_bool variation then
125
+ boolean_flag.variation_for_all_users(variation_for_boolean(variation))
126
+ else
127
+ on(true).clear_rules.clear_user_targets.fallthrough_variation(variation)
128
+ end
129
+ end
130
+
131
+ #
132
+ # Sets the flag to always return the specified variation value for all users.
133
+ #
134
+ # The value may be of any valid JSON type. This method changes the
135
+ # flag to have only a single variation, which is this value, and to return the same
136
+ # variation regardless of whether targeting is on or off. Any existing targets or rules
137
+ # are removed.
138
+ #
139
+ # @param value [Object] the desired value to be returned for all users
140
+ # @return [FlagBuilder] the builder
141
+ #
142
+ def value_for_all_users(value)
143
+ variations(value).variation_for_all_users(0)
144
+ end
145
+
146
+ #
147
+ # Sets the flag to return the specified variation for a specific user key when targeting
148
+ # is on.
149
+ #
150
+ # This has no effect when targeting is turned off for the flag.
151
+ #
152
+ # If the flag was previously configured with other variations and the variation specified is a boolean,
153
+ # this also changes it to a boolean flag.
154
+ #
155
+ # @param user_key [String] a user key
156
+ # @param variation [Boolean, Integer] true or false or the desired variation index to return:
157
+ # 0 for the first, 1 for the second, etc.
158
+ # @return [FlagBuilder] the builder
159
+ #
160
+ def variation_for_user(user_key, variation)
161
+ if LaunchDarkly::Impl::Util.is_bool variation then
162
+ boolean_flag.variation_for_user(user_key, variation_for_boolean(variation))
163
+ else
164
+ if @targets.nil? then
165
+ @targets = Hash.new
166
+ end
167
+ @variations.count.times do | i |
168
+ if i == variation then
169
+ if @targets[i].nil? then
170
+ @targets[i] = [user_key]
171
+ else
172
+ @targets[i].push(user_key)
173
+ end
174
+ elsif not @targets[i].nil? then
175
+ @targets[i].delete(user_key)
176
+ end
177
+ end
178
+ self
179
+ end
180
+ end
181
+
182
+ #
183
+ # Starts defining a flag rule, using the "is one of" operator.
184
+ #
185
+ # @example create a rule that returns `true` if the name is "Patsy" or "Edina"
186
+ # testData.flag("flag")
187
+ # .if_match(:name, 'Patsy', 'Edina')
188
+ # .then_return(true);
189
+ #
190
+ # @param attribute [Symbol] the user attribute to match against
191
+ # @param values [Array<Object>] values to compare to
192
+ # @return [FlagRuleBuilder] a flag rule builder
193
+ #
194
+ # @see FlagRuleBuilder#then_return
195
+ # @see FlagRuleBuilder#and_match
196
+ # @see FlagRuleBuilder#and_not_match
197
+ #
198
+ def if_match(attribute, *values)
199
+ FlagRuleBuilder.new(self).and_match(attribute, *values)
200
+ end
201
+
202
+ #
203
+ # Starts defining a flag rule, using the "is not one of" operator.
204
+ #
205
+ # @example create a rule that returns `true` if the name is neither "Saffron" nor "Bubble"
206
+ # testData.flag("flag")
207
+ # .if_not_match(:name, 'Saffron', 'Bubble')
208
+ # .then_return(true)
209
+ #
210
+ # @param attribute [Symbol] the user attribute to match against
211
+ # @param values [Array<Object>] values to compare to
212
+ # @return [FlagRuleBuilder] a flag rule builder
213
+ #
214
+ # @see FlagRuleBuilder#then_return
215
+ # @see FlagRuleBuilder#and_match
216
+ # @see FlagRuleBuilder#and_not_match
217
+ #
218
+ def if_not_match(attribute, *values)
219
+ FlagRuleBuilder.new(self).and_not_match(attribute, *values)
220
+ end
221
+
222
+ #
223
+ # Removes any existing user targets from the flag.
224
+ # This undoes the effect of methods like {#variation_for_user}
225
+ #
226
+ # @return [FlagBuilder] the same builder
227
+ #
228
+ def clear_user_targets
229
+ @targets = nil
230
+ self
231
+ end
232
+
233
+ #
234
+ # Removes any existing rules from the flag.
235
+ # This undoes the effect of methods like {#if_match}
236
+ #
237
+ # @return [FlagBuilder] the same builder
238
+ #
239
+ def clear_rules
240
+ @rules = nil
241
+ self
242
+ end
243
+
244
+ # @private
245
+ def add_rule(rule)
246
+ if @rules.nil? then
247
+ @rules = Array.new
248
+ end
249
+ @rules.push(rule)
250
+ self
251
+ end
252
+
253
+ #
254
+ # A shortcut for setting the flag to use the standard boolean configuration.
255
+ #
256
+ # This is the default for all new flags created with {TestData#flag}.
257
+ # The flag will have two variations, `true` and `false` (in that order);
258
+ # it will return `false` whenever targeting is off, and `true` when targeting is on
259
+ # if no other settings specify otherwise.
260
+ #
261
+ # @return [FlagBuilder] the builder
262
+ #
263
+ def boolean_flag
264
+ if is_boolean_flag then
265
+ self
266
+ else
267
+ variations(true, false)
268
+ .fallthrough_variation(TRUE_VARIATION_INDEX)
269
+ .off_variation(FALSE_VARIATION_INDEX)
270
+ end
271
+ end
272
+
273
+ # @private
274
+ def build(version)
275
+ res = { key: @key,
276
+ version: version,
277
+ on: @on,
278
+ variations: @variations,
279
+ }
280
+
281
+ unless @off_variation.nil? then
282
+ res[:offVariation] = @off_variation
283
+ end
284
+
285
+ unless @fallthrough_variation.nil? then
286
+ res[:fallthrough] = { variation: @fallthrough_variation }
287
+ end
288
+
289
+ unless @targets.nil? then
290
+ res[:targets] = @targets.collect do | variation, values |
291
+ { variation: variation, values: values }
292
+ end
293
+ end
294
+
295
+ unless @rules.nil? then
296
+ res[:rules] = @rules.each_with_index.collect { | rule, i | rule.build(i) }
297
+ end
298
+
299
+ res
300
+ end
301
+
302
+ #
303
+ # A builder for feature flag rules to be used with {FlagBuilder}.
304
+ #
305
+ # In the LaunchDarkly model, a flag can have any number of rules, and a rule can have any number of
306
+ # clauses. A clause is an individual test such as "name is 'X'". A rule matches a user if all of the
307
+ # rule's clauses match the user.
308
+ #
309
+ # To start defining a rule, use one of the flag builder's matching methods such as
310
+ # {FlagBuilder#if_match}. This defines the first clause for the rule.
311
+ # Optionally, you may add more clauses with the rule builder's methods such as
312
+ # {#and_match} or {#and_not_match}.
313
+ # Finally, call {#then_return} to finish defining the rule.
314
+ #
315
+ class FlagRuleBuilder
316
+ # @private
317
+ FlagRuleClause = Struct.new(:attribute, :op, :values, :negate, keyword_init: true)
318
+
319
+ # @private
320
+ def initialize(flag_builder)
321
+ @flag_builder = flag_builder
322
+ @clauses = Array.new
323
+ end
324
+
325
+ # @private
326
+ def intialize_copy(other)
327
+ super(other)
328
+ @clauses = @clauses.clone
329
+ end
330
+
331
+ #
332
+ # Adds another clause, using the "is one of" operator.
333
+ #
334
+ # @example create a rule that returns `true` if the name is "Patsy" and the country is "gb"
335
+ # testData.flag("flag")
336
+ # .if_match(:name, 'Patsy')
337
+ # .and_match(:country, 'gb')
338
+ # .then_return(true)
339
+ #
340
+ # @param attribute [Symbol] the user attribute to match against
341
+ # @param values [Array<Object>] values to compare to
342
+ # @return [FlagRuleBuilder] the rule builder
343
+ #
344
+ def and_match(attribute, *values)
345
+ @clauses.push(FlagRuleClause.new(
346
+ attribute: attribute,
347
+ op: 'in',
348
+ values: values,
349
+ negate: false
350
+ ))
351
+ self
352
+ end
353
+
354
+ #
355
+ # Adds another clause, using the "is not one of" operator.
356
+ #
357
+ # @example create a rule that returns `true` if the name is "Patsy" and the country is not "gb"
358
+ # testData.flag("flag")
359
+ # .if_match(:name, 'Patsy')
360
+ # .and_not_match(:country, 'gb')
361
+ # .then_return(true)
362
+ #
363
+ # @param attribute [Symbol] the user attribute to match against
364
+ # @param values [Array<Object>] values to compare to
365
+ # @return [FlagRuleBuilder] the rule builder
366
+ #
367
+ def and_not_match(attribute, *values)
368
+ @clauses.push(FlagRuleClause.new(
369
+ attribute: attribute,
370
+ op: 'in',
371
+ values: values,
372
+ negate: true
373
+ ))
374
+ self
375
+ end
376
+
377
+ #
378
+ # Finishes defining the rule, specifying the result as either a boolean
379
+ # or a variation index.
380
+ #
381
+ # If the flag was previously configured with other variations and the variation specified is a boolean,
382
+ # this also changes it to a boolean flag.
383
+ #
384
+ # @param variation [Boolean, Integer] true or false or the desired variation index:
385
+ # 0 for the first, 1 for the second, etc.
386
+ # @return [FlagBuilder] the flag builder with this rule added
387
+ #
388
+ def then_return(variation)
389
+ if LaunchDarkly::Impl::Util.is_bool variation then
390
+ @variation = @flag_builder.variation_for_boolean(variation)
391
+ @flag_builder.boolean_flag.add_rule(self)
392
+ else
393
+ @variation = variation
394
+ @flag_builder.add_rule(self)
395
+ end
396
+ end
397
+
398
+ # @private
399
+ def build(ri)
400
+ {
401
+ id: 'rule' + ri.to_s,
402
+ variation: @variation,
403
+ clauses: @clauses.collect(&:to_h)
404
+ }
405
+ end
406
+ end
407
+
408
+ # @private
409
+ def variation_for_boolean(variation)
410
+ variation ? TRUE_VARIATION_INDEX : FALSE_VARIATION_INDEX
411
+ end
412
+
413
+ private
414
+
415
+ TRUE_VARIATION_INDEX = 0
416
+ FALSE_VARIATION_INDEX = 1
417
+
418
+ def is_boolean_flag
419
+ @variations.size == 2 &&
420
+ @variations[TRUE_VARIATION_INDEX] == true &&
421
+ @variations[FALSE_VARIATION_INDEX] == false
422
+ end
423
+
424
+ def deep_copy_hash(from)
425
+ to = Hash.new
426
+ from.each { |k, v| to[k] = v.clone }
427
+ to
428
+ end
429
+
430
+ def deep_copy_array(from)
431
+ to = Array.new
432
+ from.each { |v| to.push(v.clone) }
433
+ to
434
+ end
435
+ end
436
+ end
437
+ end
438
+ end
@@ -0,0 +1,209 @@
1
+ require 'ldclient-rb/impl/integrations/test_data/test_data_source'
2
+ require 'ldclient-rb/integrations/test_data/flag_builder'
3
+
4
+ require 'concurrent/atomics'
5
+
6
+ module LaunchDarkly
7
+ module Integrations
8
+ #
9
+ # A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK
10
+ # client in test scenarios.
11
+ #
12
+ # Unlike {LaunchDarkly::Integrations::FileData}, this mechanism does not use any external resources. It
13
+ # provides only the data that the application has put into it using the {#update} method.
14
+ #
15
+ # @example
16
+ # td = LaunchDarkly::Integrations::TestData.data_source
17
+ # td.update(td.flag("flag-key-1").variation_for_all_users(true))
18
+ # config = LaunchDarkly::Config.new(data_source: td)
19
+ # client = LaunchDarkly::LDClient.new('sdkKey', config)
20
+ # # flags can be updated at any time:
21
+ # td.update(td.flag("flag-key-2")
22
+ # .variation_for_user("some-user-key", true)
23
+ # .fallthrough_variation(false))
24
+ #
25
+ # The above example uses a simple boolean flag, but more complex configurations are possible using
26
+ # the methods of the {FlagBuilder} that is returned by {#flag}. {FlagBuilder}
27
+ # supports many of the ways a flag can be configured on the LaunchDarkly dashboard, but does not
28
+ # currently support 1. rule operators other than "in" and "not in", or 2. percentage rollouts.
29
+ #
30
+ # If the same `TestData` instance is used to configure multiple `LDClient` instances,
31
+ # any changes made to the data will propagate to all of the `LDClient`s.
32
+ #
33
+ # @since 6.3.0
34
+ #
35
+ class TestData
36
+ # Creates a new instance of the test data source.
37
+ #
38
+ # @return [TestData] a new configurable test data source
39
+ def self.data_source
40
+ self.new
41
+ end
42
+
43
+ # @private
44
+ def initialize
45
+ @flag_builders = Hash.new
46
+ @current_flags = Hash.new
47
+ @current_segments = Hash.new
48
+ @instances = Array.new
49
+ @instances_lock = Concurrent::ReadWriteLock.new
50
+ @lock = Concurrent::ReadWriteLock.new
51
+ end
52
+
53
+ #
54
+ # Called internally by the SDK to determine what arguments to pass to call
55
+ # You do not need to call this method.
56
+ #
57
+ # @private
58
+ def arity
59
+ 2
60
+ end
61
+
62
+ #
63
+ # Called internally by the SDK to associate this test data source with an {@code LDClient} instance.
64
+ # You do not need to call this method.
65
+ #
66
+ # @private
67
+ def call(_, config)
68
+ impl = LaunchDarkly::Impl::Integrations::TestData::TestDataSource.new(config.feature_store, self)
69
+ @instances_lock.with_write_lock { @instances.push(impl) }
70
+ impl
71
+ end
72
+
73
+ #
74
+ # Creates or copies a {FlagBuilder} for building a test flag configuration.
75
+ #
76
+ # If this flag key has already been defined in this `TestData` instance, then the builder
77
+ # starts with the same configuration that was last provided for this flag.
78
+ #
79
+ # Otherwise, it starts with a new default configuration in which the flag has `true` and
80
+ # `false` variations, is `true` for all users when targeting is turned on and
81
+ # `false` otherwise, and currently has targeting turned on. You can change any of those
82
+ # properties, and provide more complex behavior, using the {FlagBuilder} methods.
83
+ #
84
+ # Once you have set the desired configuration, pass the builder to {#update}.
85
+ #
86
+ # @param key [String] the flag key
87
+ # @return [FlagBuilder] a flag configuration builder
88
+ #
89
+ def flag(key)
90
+ existing_builder = @lock.with_read_lock { @flag_builders[key] }
91
+ if existing_builder.nil? then
92
+ FlagBuilder.new(key).boolean_flag
93
+ else
94
+ existing_builder.clone
95
+ end
96
+ end
97
+
98
+ #
99
+ # Updates the test data with the specified flag configuration.
100
+ #
101
+ # This has the same effect as if a flag were added or modified on the LaunchDarkly dashboard.
102
+ # It immediately propagates the flag change to any `LDClient` instance(s) that you have
103
+ # already configured to use this `TestData`. If no `LDClient` has been started yet,
104
+ # it simply adds this flag to the test data which will be provided to any `LDClient` that
105
+ # you subsequently configure.
106
+ #
107
+ # Any subsequent changes to this {FlagBuilder} instance do not affect the test data,
108
+ # unless you call {#update} again.
109
+ #
110
+ # @param flag_builder [FlagBuilder] a flag configuration builder
111
+ # @return [TestData] the TestData instance
112
+ #
113
+ def update(flag_builder)
114
+ new_flag = nil
115
+ @lock.with_write_lock do
116
+ @flag_builders[flag_builder.key] = flag_builder
117
+ version = 0
118
+ flag_key = flag_builder.key.to_sym
119
+ if @current_flags[flag_key] then
120
+ version = @current_flags[flag_key][:version]
121
+ end
122
+ new_flag = flag_builder.build(version+1)
123
+ @current_flags[flag_key] = new_flag
124
+ end
125
+ update_item(FEATURES, new_flag)
126
+ self
127
+ end
128
+
129
+ #
130
+ # Copies a full feature flag data model object into the test data.
131
+ #
132
+ # It immediately propagates the flag change to any `LDClient` instance(s) that you have already
133
+ # configured to use this `TestData`. If no `LDClient` has been started yet, it simply adds
134
+ # this flag to the test data which will be provided to any LDClient that you subsequently
135
+ # configure.
136
+ #
137
+ # Use this method if you need to use advanced flag configuration properties that are not supported by
138
+ # the simplified {FlagBuilder} API. Otherwise it is recommended to use the regular {flag}/{update}
139
+ # mechanism to avoid dependencies on details of the data model.
140
+ #
141
+ # You cannot make incremental changes with {flag}/{update} to a flag that has been added in this way;
142
+ # you can only replace it with an entirely new flag configuration.
143
+ #
144
+ # @param flag [Hash] the flag configuration
145
+ # @return [TestData] the TestData instance
146
+ #
147
+ def use_preconfigured_flag(flag)
148
+ use_preconfigured_item(FEATURES, flag, @current_flags)
149
+ end
150
+
151
+ #
152
+ # Copies a full user segment data model object into the test data.
153
+ #
154
+ # It immediately propagates the change to any `LDClient` instance(s) that you have already
155
+ # configured to use this `TestData`. If no `LDClient` has been started yet, it simply adds
156
+ # this segment to the test data which will be provided to any LDClient that you subsequently
157
+ # configure.
158
+ #
159
+ # This method is currently the only way to inject user segment data, since there is no builder
160
+ # API for segments. It is mainly intended for the SDK's own tests of user segment functionality,
161
+ # since application tests that need to produce a desired evaluation state could do so more easily
162
+ # by just setting flag values.
163
+ #
164
+ # @param segment [Hash] the segment configuration
165
+ # @return [TestData] the TestData instance
166
+ #
167
+ def use_preconfigured_segment(segment)
168
+ use_preconfigured_item(SEGMENTS, segment, @current_segments)
169
+ end
170
+
171
+ private def use_preconfigured_item(kind, item, current)
172
+ key = item[:key].to_sym
173
+ @lock.with_write_lock do
174
+ old_item = current[key]
175
+ if !old_item.nil? then
176
+ item = item.clone
177
+ item[:version] = old_item[:version] + 1
178
+ end
179
+ current[key] = item
180
+ end
181
+ update_item(kind, item)
182
+ self
183
+ end
184
+
185
+ private def update_item(kind, item)
186
+ @instances_lock.with_read_lock do
187
+ @instances.each do | instance |
188
+ instance.upsert(kind, item)
189
+ end
190
+ end
191
+ end
192
+
193
+ # @private
194
+ def make_init_data
195
+ @lock.with_read_lock do
196
+ {
197
+ FEATURES => @current_flags.clone,
198
+ SEGMENTS => @current_segments.clone
199
+ }
200
+ end
201
+ end
202
+
203
+ # @private
204
+ def closed_instance(instance)
205
+ @instances_lock.with_write_lock { @instances.delete(instance) }
206
+ end
207
+ end
208
+ end
209
+ end
@@ -4,6 +4,11 @@ require "ldclient-rb/expiring_cache"
4
4
 
5
5
  module LaunchDarkly
6
6
  module Integrations
7
+ #
8
+ # Support code that may be helpful in creating integrations.
9
+ #
10
+ # @since 5.5.0
11
+ #
7
12
  module Util
8
13
  #
9
14
  # CachingStoreWrapper is a partial implementation of the {LaunchDarkly::Interfaces::FeatureStore}