ldclient-rb 2.5.0 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -26,6 +26,18 @@ module LaunchDarkly
26
26
  make_request("/sdk/latest-flags/" + key)
27
27
  end
28
28
 
29
+ def request_all_segments()
30
+ make_request("/sdk/latest-segments")
31
+ end
32
+
33
+ def request_segment(key)
34
+ make_request("/sdk/latest-segments/" + key)
35
+ end
36
+
37
+ def request_all_data()
38
+ make_request("/sdk/latest-all")
39
+ end
40
+
29
41
  def make_request(path)
30
42
  uri = @config.base_uri + path
31
43
  res = @client.get (uri) do |req|
@@ -10,11 +10,16 @@ module LaunchDarkly
10
10
  INDIRECT_PATCH = :'indirect/patch'
11
11
  READ_TIMEOUT_SECONDS = 300 # 5 minutes; the stream should send a ping every 3 minutes
12
12
 
13
+ KEY_PATHS = {
14
+ FEATURES => "/flags/",
15
+ SEGMENTS => "/segments/"
16
+ }
17
+
13
18
  class StreamProcessor
14
19
  def initialize(sdk_key, config, requestor)
15
20
  @sdk_key = sdk_key
16
21
  @config = config
17
- @store = config.feature_store
22
+ @feature_store = config.feature_store
18
23
  @requestor = requestor
19
24
  @initialized = Concurrent::AtomicBoolean.new(false)
20
25
  @started = Concurrent::AtomicBoolean.new(false)
@@ -36,7 +41,7 @@ module LaunchDarkly
36
41
  'User-Agent' => 'RubyClient/' + LaunchDarkly::VERSION
37
42
  }
38
43
  opts = {:headers => headers, :with_credentials => true, :proxy => @config.proxy, :read_timeout => READ_TIMEOUT_SECONDS}
39
- @es = Celluloid::EventSource.new(@config.stream_uri + "/flags", opts) do |conn|
44
+ @es = Celluloid::EventSource.new(@config.stream_uri + "/all", opts) do |conn|
40
45
  conn.on(PUT) { |message| process_message(message, PUT) }
41
46
  conn.on(PATCH) { |message| process_message(message, PATCH) }
42
47
  conn.on(DELETE) { |message| process_message(message, DELETE) }
@@ -66,30 +71,61 @@ module LaunchDarkly
66
71
  end
67
72
  end
68
73
 
74
+ private
75
+
69
76
  def process_message(message, method)
70
77
  @config.logger.debug("[LDClient] Stream received #{method} message: #{message.data}")
71
78
  if method == PUT
72
79
  message = JSON.parse(message.data, symbolize_names: true)
73
- @store.init(message)
80
+ @feature_store.init({
81
+ FEATURES => message[:data][:flags],
82
+ SEGMENTS => message[:data][:segments]
83
+ })
74
84
  @initialized.make_true
75
85
  @config.logger.info("[LDClient] Stream initialized")
76
86
  elsif method == PATCH
77
87
  message = JSON.parse(message.data, symbolize_names: true)
78
- @store.upsert(message[:path][1..-1], message[:data])
88
+ for kind in [FEATURES, SEGMENTS]
89
+ key = key_for_path(kind, message[:path])
90
+ if key
91
+ @feature_store.upsert(kind, message[:data])
92
+ break
93
+ end
94
+ end
79
95
  elsif method == DELETE
80
96
  message = JSON.parse(message.data, symbolize_names: true)
81
- @store.delete(message[:path][1..-1], message[:version])
97
+ for kind in [FEATURES, SEGMENTS]
98
+ key = key_for_path(kind, message[:path])
99
+ if key
100
+ @feature_store.delete(kind, key, message[:version])
101
+ break
102
+ end
103
+ end
82
104
  elsif method == INDIRECT_PUT
83
- @store.init(@requestor.request_all_flags)
105
+ all_data = @requestor.request_all_data
106
+ @feature_store.init({
107
+ FEATURES => all_data[:flags],
108
+ SEGMENTS => all_data[:segments]
109
+ })
84
110
  @initialized.make_true
85
111
  @config.logger.info("[LDClient] Stream initialized (via indirect message)")
86
112
  elsif method == INDIRECT_PATCH
87
- @store.upsert(message.data, @requestor.request_flag(message.data))
113
+ key = feature_key_for_path(message.data)
114
+ if key
115
+ @feature_store.upsert(FEATURES, @requestor.request_flag(key))
116
+ else
117
+ key = segment_key_for_path(message.data)
118
+ if key
119
+ @feature_store.upsert(SEGMENTS, key, @requestor.request_segment(key))
120
+ end
121
+ end
88
122
  else
89
123
  @config.logger.warn("[LDClient] Unknown message received: #{method}")
90
124
  end
91
125
  end
92
126
 
93
- private :process_message
127
+ def key_for_path(kind, path)
128
+ path.start_with?(KEY_PATHS[kind]) ? path[KEY_PATHS[kind].length..-1] : nil
129
+ end
94
130
  end
95
131
  end
@@ -1,3 +1,3 @@
1
1
  module LaunchDarkly
2
- VERSION = "2.5.0"
2
+ VERSION = "3.0.0"
3
3
  end
@@ -4,6 +4,14 @@ describe LaunchDarkly::Evaluation do
4
4
  subject { LaunchDarkly::Evaluation }
5
5
  let(:features) { LaunchDarkly::InMemoryFeatureStore.new }
6
6
 
7
+ let(:user) {
8
+ {
9
+ key: "userkey",
10
+ email: "test@example.com",
11
+ name: "Bob"
12
+ }
13
+ }
14
+
7
15
  include LaunchDarkly::Evaluation
8
16
 
9
17
  describe "evaluate" do
@@ -60,7 +68,7 @@ describe LaunchDarkly::Evaluation do
60
68
  variations: ['d', 'e'],
61
69
  version: 2
62
70
  }
63
- features.upsert('feature1', flag1)
71
+ features.upsert(LaunchDarkly::FEATURES, flag1)
64
72
  user = { key: 'x' }
65
73
  events_should_be = [{kind: 'feature', key: 'feature1', value: 'd', version: 2, prereqOf: 'feature0'}]
66
74
  expect(evaluate(flag, user, features)).to eq({value: 'b', events: events_should_be})
@@ -83,7 +91,7 @@ describe LaunchDarkly::Evaluation do
83
91
  variations: ['d', 'e'],
84
92
  version: 2
85
93
  }
86
- features.upsert('feature1', flag1)
94
+ features.upsert(LaunchDarkly::FEATURES, flag1)
87
95
  user = { key: 'x' }
88
96
  events_should_be = [{kind: 'feature', key: 'feature1', value: 'e', version: 2, prereqOf: 'feature0'}]
89
97
  expect(evaluate(flag, user, features)).to eq({value: 'a', events: events_should_be})
@@ -133,19 +141,47 @@ describe LaunchDarkly::Evaluation do
133
141
  it "can match built-in attribute" do
134
142
  user = { key: 'x', name: 'Bob' }
135
143
  clause = { attribute: 'name', op: 'in', values: ['Bob'] }
136
- expect(clause_match_user(clause, user)).to be true
144
+ expect(clause_match_user(clause, user, features)).to be true
137
145
  end
138
146
 
139
147
  it "can match custom attribute" do
140
148
  user = { key: 'x', name: 'Bob', custom: { legs: 4 } }
141
149
  clause = { attribute: 'legs', op: 'in', values: [4] }
142
- expect(clause_match_user(clause, user)).to be true
150
+ expect(clause_match_user(clause, user, features)).to be true
143
151
  end
144
152
 
145
153
  it "returns false for missing attribute" do
146
154
  user = { key: 'x', name: 'Bob' }
147
155
  clause = { attribute: 'legs', op: 'in', values: [4] }
148
- expect(clause_match_user(clause, user)).to be false
156
+ expect(clause_match_user(clause, user, features)).to be false
157
+ end
158
+
159
+ it "can be negated" do
160
+ user = { key: 'x', name: 'Bob' }
161
+ clause = { attribute: 'name', op: 'in', values: ['Bob'], negate: true }
162
+ expect(clause_match_user(clause, user, features)).to be false
163
+ end
164
+
165
+ it "retrieves segment from segment store for segmentMatch operator" do
166
+ segment = {
167
+ key: 'segkey',
168
+ included: [ 'userkey' ],
169
+ version: 1,
170
+ deleted: false
171
+ }
172
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
173
+
174
+ user = { key: 'userkey' }
175
+ clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] }
176
+
177
+ expect(clause_match_user(clause, user, features)).to be true
178
+ end
179
+
180
+ it "falls through with no errors if referenced segment is not found" do
181
+ user = { key: 'userkey' }
182
+ clause = { attribute: '', op: 'segmentMatch', values: ['segkey'] }
183
+
184
+ expect(clause_match_user(clause, user, features)).to be false
149
185
  end
150
186
 
151
187
  it "can be negated" do
@@ -153,7 +189,7 @@ describe LaunchDarkly::Evaluation do
153
189
  clause = { attribute: 'name', op: 'in', values: ['Bob'] }
154
190
  expect {
155
191
  clause[:negate] = true
156
- }.to change {clause_match_user(clause, user)}.from(true).to(false)
192
+ }.to change {clause_match_user(clause, user, features)}.from(true).to(false)
157
193
  end
158
194
  end
159
195
 
@@ -255,7 +291,7 @@ describe LaunchDarkly::Evaluation do
255
291
  it "should return #{shouldBe} for #{value1} #{op} #{value2}" do
256
292
  user = { key: 'x', custom: { foo: value1 } }
257
293
  clause = { attribute: 'foo', op: op, values: [value2] }
258
- expect(clause_match_user(clause, user)).to be shouldBe
294
+ expect(clause_match_user(clause, user, features)).to be shouldBe
259
295
  end
260
296
  end
261
297
  end
@@ -313,4 +349,165 @@ describe LaunchDarkly::Evaluation do
313
349
  expect(result).to eq(0.0)
314
350
  end
315
351
  end
352
+
353
+ def make_flag(key)
354
+ {
355
+ key: key,
356
+ rules: [],
357
+ variations: [ false, true ],
358
+ on: true,
359
+ fallthrough: { variation: 0 },
360
+ version: 1
361
+ }
362
+ end
363
+
364
+ def make_segment(key)
365
+ {
366
+ key: key,
367
+ included: [],
368
+ excluded: [],
369
+ salt: 'abcdef',
370
+ version: 1
371
+ }
372
+ end
373
+
374
+ def make_segment_match_clause(segment)
375
+ {
376
+ op: :segmentMatch,
377
+ values: [ segment[:key] ],
378
+ negate: false
379
+ }
380
+ end
381
+
382
+ def make_user_matching_clause(user, attr)
383
+ {
384
+ attribute: attr.to_s,
385
+ op: :in,
386
+ values: [ user[attr.to_sym] ],
387
+ negate: false
388
+ }
389
+ end
390
+
391
+ describe 'segment matching' do
392
+ it 'explicitly includes user' do
393
+ segment = make_segment('segkey')
394
+ segment[:included] = [ user[:key] ]
395
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
396
+ clause = make_segment_match_clause(segment)
397
+
398
+ result = clause_match_user(clause, user, features)
399
+ expect(result).to be true
400
+ end
401
+
402
+ it 'explicitly excludes user' do
403
+ segment = make_segment('segkey')
404
+ segment[:excluded] = [ user[:key] ]
405
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
406
+ clause = make_segment_match_clause(segment)
407
+
408
+ result = clause_match_user(clause, user, features)
409
+ expect(result).to be false
410
+ end
411
+
412
+ it 'both includes and excludes user; include takes priority' do
413
+ segment = make_segment('segkey')
414
+ segment[:included] = [ user[:key] ]
415
+ segment[:excluded] = [ user[:key] ]
416
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
417
+ clause = make_segment_match_clause(segment)
418
+
419
+ result = clause_match_user(clause, user, features)
420
+ expect(result).to be true
421
+ end
422
+
423
+ it 'matches user by rule when weight is absent' do
424
+ segClause = make_user_matching_clause(user, :email)
425
+ segRule = {
426
+ clauses: [ segClause ]
427
+ }
428
+ segment = make_segment('segkey')
429
+ segment[:rules] = [ segRule ]
430
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
431
+ clause = make_segment_match_clause(segment)
432
+
433
+ result = clause_match_user(clause, user, features)
434
+ expect(result).to be true
435
+ end
436
+
437
+ it 'matches user by rule when weight is nil' do
438
+ segClause = make_user_matching_clause(user, :email)
439
+ segRule = {
440
+ clauses: [ segClause ],
441
+ weight: nil
442
+ }
443
+ segment = make_segment('segkey')
444
+ segment[:rules] = [ segRule ]
445
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
446
+ clause = make_segment_match_clause(segment)
447
+
448
+ result = clause_match_user(clause, user, features)
449
+ expect(result).to be true
450
+ end
451
+
452
+ it 'matches user with full rollout' do
453
+ segClause = make_user_matching_clause(user, :email)
454
+ segRule = {
455
+ clauses: [ segClause ],
456
+ weight: 100000
457
+ }
458
+ segment = make_segment('segkey')
459
+ segment[:rules] = [ segRule ]
460
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
461
+ clause = make_segment_match_clause(segment)
462
+
463
+ result = clause_match_user(clause, user, features)
464
+ expect(result).to be true
465
+ end
466
+
467
+ it "doesn't match user with zero rollout" do
468
+ segClause = make_user_matching_clause(user, :email)
469
+ segRule = {
470
+ clauses: [ segClause ],
471
+ weight: 0
472
+ }
473
+ segment = make_segment('segkey')
474
+ segment[:rules] = [ segRule ]
475
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
476
+ clause = make_segment_match_clause(segment)
477
+
478
+ result = clause_match_user(clause, user, features)
479
+ expect(result).to be false
480
+ end
481
+
482
+ it "matches user with multiple clauses" do
483
+ segClause1 = make_user_matching_clause(user, :email)
484
+ segClause2 = make_user_matching_clause(user, :name)
485
+ segRule = {
486
+ clauses: [ segClause1, segClause2 ]
487
+ }
488
+ segment = make_segment('segkey')
489
+ segment[:rules] = [ segRule ]
490
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
491
+ clause = make_segment_match_clause(segment)
492
+
493
+ result = clause_match_user(clause, user, features)
494
+ expect(result).to be true
495
+ end
496
+
497
+ it "doesn't match user with multiple clauses if a clause doesn't match" do
498
+ segClause1 = make_user_matching_clause(user, :email)
499
+ segClause2 = make_user_matching_clause(user, :name)
500
+ segClause2[:values] = [ 'wrong' ]
501
+ segRule = {
502
+ clauses: [ segClause1, segClause2 ]
503
+ }
504
+ segment = make_segment('segkey')
505
+ segment[:rules] = [ segRule ]
506
+ features.upsert(LaunchDarkly::SEGMENTS, segment)
507
+ clause = make_segment_match_clause(segment)
508
+
509
+ result = clause_match_user(clause, user, features)
510
+ expect(result).to be false
511
+ end
512
+ end
316
513
  end
@@ -31,7 +31,7 @@ RSpec.shared_examples "feature_store" do |create_store_method|
31
31
 
32
32
  let!(:store) do
33
33
  s = create_store_method.call()
34
- s.init({ key0 => feature0 })
34
+ s.init(LaunchDarkly::FEATURES => { key0 => feature0 })
35
35
  s
36
36
  end
37
37
 
@@ -48,15 +48,15 @@ RSpec.shared_examples "feature_store" do |create_store_method|
48
48
  end
49
49
 
50
50
  it "can get existing feature with symbol key" do
51
- expect(store.get(key0)).to eq feature0
51
+ expect(store.get(LaunchDarkly::FEATURES, key0)).to eq feature0
52
52
  end
53
53
 
54
54
  it "can get existing feature with string key" do
55
- expect(store.get(key0.to_s)).to eq feature0
55
+ expect(store.get(LaunchDarkly::FEATURES, key0.to_s)).to eq feature0
56
56
  end
57
57
 
58
58
  it "gets nil for nonexisting feature" do
59
- expect(store.get('nope')).to be_nil
59
+ expect(store.get(LaunchDarkly::FEATURES, 'nope')).to be_nil
60
60
  end
61
61
 
62
62
  it "can get all features" do
@@ -64,8 +64,8 @@ RSpec.shared_examples "feature_store" do |create_store_method|
64
64
  feature1[:key] = "test-feature-flag1"
65
65
  feature1[:version] = 5
66
66
  feature1[:on] = false
67
- store.upsert(:"test-feature-flag1", feature1)
68
- expect(store.all).to eq ({ key0 => feature0, :"test-feature-flag1" => feature1 })
67
+ store.upsert(LaunchDarkly::FEATURES, feature1)
68
+ expect(store.all(LaunchDarkly::FEATURES)).to eq ({ key0 => feature0, :"test-feature-flag1" => feature1 })
69
69
  end
70
70
 
71
71
  it "can add new feature" do
@@ -73,40 +73,40 @@ RSpec.shared_examples "feature_store" do |create_store_method|
73
73
  feature1[:key] = "test-feature-flag1"
74
74
  feature1[:version] = 5
75
75
  feature1[:on] = false
76
- store.upsert(:"test-feature-flag1", feature1)
77
- expect(store.get(:"test-feature-flag1")).to eq feature1
76
+ store.upsert(LaunchDarkly::FEATURES, feature1)
77
+ expect(store.get(LaunchDarkly::FEATURES, :"test-feature-flag1")).to eq feature1
78
78
  end
79
79
 
80
80
  it "can update feature with newer version" do
81
81
  f1 = new_version_plus(feature0, 1, { on: !feature0[:on] })
82
- store.upsert(key0, f1)
83
- expect(store.get(key0)).to eq f1
82
+ store.upsert(LaunchDarkly::FEATURES, f1)
83
+ expect(store.get(LaunchDarkly::FEATURES, key0)).to eq f1
84
84
  end
85
85
 
86
86
  it "cannot update feature with same version" do
87
87
  f1 = new_version_plus(feature0, 0, { on: !feature0[:on] })
88
- store.upsert(key0, f1)
89
- expect(store.get(key0)).to eq feature0
88
+ store.upsert(LaunchDarkly::FEATURES, f1)
89
+ expect(store.get(LaunchDarkly::FEATURES, key0)).to eq feature0
90
90
  end
91
91
 
92
92
  it "cannot update feature with older version" do
93
93
  f1 = new_version_plus(feature0, -1, { on: !feature0[:on] })
94
- store.upsert(key0, f1)
95
- expect(store.get(key0)).to eq feature0
94
+ store.upsert(LaunchDarkly::FEATURES, f1)
95
+ expect(store.get(LaunchDarkly::FEATURES, key0)).to eq feature0
96
96
  end
97
97
 
98
98
  it "can delete feature with newer version" do
99
- store.delete(key0, feature0[:version] + 1)
100
- expect(store.get(key0)).to be_nil
99
+ store.delete(LaunchDarkly::FEATURES, key0, feature0[:version] + 1)
100
+ expect(store.get(LaunchDarkly::FEATURES, key0)).to be_nil
101
101
  end
102
102
 
103
103
  it "cannot delete feature with same version" do
104
- store.delete(key0, feature0[:version])
105
- expect(store.get(key0)).to eq feature0
104
+ store.delete(LaunchDarkly::FEATURES, key0, feature0[:version])
105
+ expect(store.get(LaunchDarkly::FEATURES, key0)).to eq feature0
106
106
  end
107
107
 
108
108
  it "cannot delete feature with older version" do
109
- store.delete(key0, feature0[:version] - 1)
110
- expect(store.get(key0)).to eq feature0
109
+ store.delete(LaunchDarkly::FEATURES, key0, feature0[:version] - 1)
110
+ expect(store.get(LaunchDarkly::FEATURES, key0)).to eq feature0
111
111
  end
112
112
  end