-
Notifications
You must be signed in to change notification settings - Fork 54
Expand file tree
/
Copy pathcontext.rb
More file actions
565 lines (490 loc) · 18.1 KB
/
Copy pathcontext.rb
File metadata and controls
565 lines (490 loc) · 18.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
require 'set'
require 'ldclient-rb/impl/context'
require 'ldclient-rb/reference'
module LaunchDarkly
# LDContext is a collection of attributes that can be referenced in flag
# evaluations and analytics events.
#
# To create an LDContext of a single kind, such as a user, you may use
# {LDContext#create} or {LDContext#with_key}.
#
# To create an LDContext with multiple kinds, use {LDContext#create_multi}.
#
# Each factory method will always return an LDContext. However, that
# LDContext may be invalid. You can check the validity of the resulting
# context, and the associated errors by calling {LDContext#valid?} and
# {LDContext#error}
class LDContext
KIND_DEFAULT = "user"
KIND_MULTI = "multi"
ERR_NOT_HASH = 'context data is not a hash'
private_constant :ERR_NOT_HASH
ERR_KEY_EMPTY = 'context key must not be null or empty'
private_constant :ERR_KEY_EMPTY
ERR_KIND_MULTI_NON_CONTEXT_ARRAY = 'context data must be an array of valid LDContexts'
private_constant :ERR_KIND_MULTI_NON_CONTEXT_ARRAY
ERR_KIND_MULTI_CANNOT_CONTAIN_MULTI = 'multi-kind context cannot contain another multi-kind context'
private_constant :ERR_KIND_MULTI_CANNOT_CONTAIN_MULTI
ERR_KIND_MULTI_WITH_NO_KINDS = 'multi-context must contain at least one kind'
private_constant :ERR_KIND_MULTI_WITH_NO_KINDS
ERR_KIND_MULTI_DUPLICATES = 'multi-kind context cannot have same kind more than once'
private_constant :ERR_KIND_MULTI_DUPLICATES
ERR_CUSTOM_NON_HASH = 'context custom must be a hash'
private_constant :ERR_CUSTOM_NON_HASH
ERR_PRIVATE_NON_ARRAY = 'context private attributes must be an array'
# @return [String, nil] Returns the key for this context
attr_reader :key
# @return [String, nil] Returns the fully qualified key for this context
attr_reader :fully_qualified_key
# @return [String, nil] Returns the kind for this context
attr_reader :kind
# @return [String, nil] Returns the error associated with this LDContext if invalid
attr_reader :error
#
# @api private
# @param key [String, nil]
# @param fully_qualified_key [String, nil]
# @param kind [String, nil]
# @param name [String, nil]
# @param anonymous [Boolean, nil]
# @param attributes [Hash, nil]
# @param private_attributes [Array<String>, nil]
# @param error [String, nil]
# @param contexts [Array<LDContext>, nil]
#
def initialize(key, fully_qualified_key, kind, name = nil, anonymous = nil, attributes = nil, private_attributes = nil, error = nil, contexts = nil)
@key = key
@fully_qualified_key = fully_qualified_key
@kind = kind
@name = name
@anonymous = anonymous || false
@attributes = attributes
@private_attributes = Set.new
(private_attributes || []).each do |attribute|
reference = Reference.create(attribute)
@private_attributes.add(reference) if reference.error.nil?
end
@error = error
@contexts = contexts
@is_multi = !contexts.nil?
end
private_class_method :new
protected attr_reader :name, :anonymous, :attributes
#
# @return [Array<Reference>] Returns the private attributes associated with this LDContext
#
def private_attributes
# TODO(sc-227265): Return a set instead of an array.
@private_attributes.to_a
end
#
# @return [Boolean] Is this LDContext a multi-kind context?
#
def multi_kind?
@is_multi
end
#
# @return [Boolean] Determine if this LDContext is considered valid
#
def valid?
@error.nil?
end
#
# For a multi-kind context:
#
# A multi-kind context is made up of two or more single-kind contexts. This method will first discard any
# single-kind contexts which are anonymous. It will then create a new multi-kind context from the remaining
# single-kind contexts. This may result in an invalid context (e.g. all single-kind contexts are anonymous).
#
# For a single-kind context:
#
# If the context is not anonymous, this method will return the current context as is and unmodified.
#
# If the context is anonymous, this method will return an invalid context.
#
def without_anonymous_contexts
contexts = multi_kind? ? @contexts : [self]
contexts = contexts.reject { |c| c.anonymous }
LDContext.create_multi(contexts)
end
#
# Returns a hash mapping each context's kind to its key.
#
# @return [Hash<Symbol, String>]
#
def keys
return {} unless valid?
return Hash[kind, key] unless multi_kind?
@contexts.map { |c| [c.kind, c.key] }.to_h
end
#
# Returns an array of context kinds.
#
# @return [Array<String>]
#
def kinds
return [] unless valid?
return [kind] unless multi_kind?
@contexts.map { |c| c.kind }
end
#
# Return an array of top level attribute keys (excluding built-in attributes)
#
# @return [Array<Symbol>]
#
def get_custom_attribute_names
return [] if @attributes.nil?
@attributes.keys
end
#
# get_value looks up the value of any attribute of the Context by name.
# This includes only attributes that are addressable in evaluations-- not
# metadata such as private attributes.
#
# For a single-kind context, the attribute name can be any custom attribute.
# It can also be one of the built-in ones like "kind", "key", or "name".
#
# For a multi-kind context, the only supported attribute name is "kind".
# Use {#individual_context} to inspect a Context for a particular kind and
# then get its attributes.
#
# This method does not support complex expressions for getting individual
# values out of JSON objects or arrays, such as "/address/street". Use
# {#get_value_for_reference} for that purpose.
#
# If the value is found, the return value is the attribute value;
# otherwise, it is nil.
#
# @param attribute [String, Symbol]
# @return [any]
#
def get_value(attribute)
reference = Reference.create_literal(attribute)
get_value_for_reference(reference)
end
#
# get_value_for_reference looks up the value of any attribute of the
# Context, or a value contained within an attribute, based on a {Reference}
# instance. This includes only attributes that are addressable in
# evaluations-- not metadata such as private attributes.
#
# This implements the same behavior that the SDK uses to resolve attribute
# references during a flag evaluation. In a single-kind context, the
# {Reference} can represent a simple attribute name-- either a built-in one
# like "name" or "key", or a custom attribute -- or, it can be a
# slash-delimited path using a JSON-Pointer-like syntax. See {Reference}
# for more details.
#
# For a multi-kind context, the only supported attribute name is "kind".
# Use {#individual_context} to inspect a Context for a particular kind and
# then get its attributes.
#
# If the value is found, the return value is the attribute value;
# otherwise, it is nil.
#
# @param reference [Reference]
# @return [any]
#
def get_value_for_reference(reference)
return nil unless valid?
return nil unless reference.is_a?(Reference)
return nil unless reference.error.nil?
first_component = reference.component(0)
return nil if first_component.nil?
if multi_kind?
if reference.depth == 1 && first_component == :kind
return kind
end
# Multi-kind contexts have no other addressable attributes
return nil
end
value = get_top_level_addressable_attribute_single_kind(first_component)
return nil if value.nil?
(1...reference.depth).each do |i|
name = reference.component(i)
return nil unless value.is_a?(Hash)
return nil unless value.has_key?(name)
value = value[name]
end
value
end
#
# Returns the number of context kinds in this context.
#
# For a valid individual context, this returns 1. For a multi-context, it
# returns the number of context kinds. For an invalid context, it returns
# zero.
#
# @return [Integer] the number of context kinds
#
def individual_context_count
return 0 unless valid?
return 1 if @contexts.nil?
@contexts.count
end
#
# Returns the single-kind LDContext corresponding to one of the kinds in
# this context.
#
# The `kind` parameter can be either a number representing a zero-based
# index, or a string representing a context kind.
#
# If this method is called on a single-kind LDContext, then the only
# allowable value for `kind` is either zero or the same value as {#kind},
# and the return value on success is the same LDContext.
#
# If the method is called on a multi-context, and `kind` is a number, it
# must be a non-negative index that is less than the number of kinds (that
# is, less than the return value of {#individual_context_count}, and the
# return value on success is one of the individual LDContexts within. Or,
# if `kind` is a string, it must match the context kind of one of the
# individual contexts.
#
# If there is no context corresponding to `kind`, the method returns nil.
#
# @param kind [Integer, String] the index or string value of a context kind
# @return [LDContext, nil] the context corresponding to that index or kind,
# or null if none.
#
def individual_context(kind)
return nil unless valid?
if kind.is_a?(Integer)
unless multi_kind?
return kind == 0 ? self : nil
end
return kind >= 0 && kind < @contexts.count ? @contexts[kind] : nil
end
return nil unless kind.is_a?(String)
unless multi_kind?
return self.kind == kind ? self : nil
end
@contexts.each do |context|
return context if context.kind == kind
end
nil
end
#
# An LDContext can be compared to other LDContexts or to a hash object. If
# a hash is provided, it is first converted to an LDContext using the
# `LDContext.create` method.
#
# @param other [LDContext, Hash]
# @return [Boolean]
#
def ==(other)
other = LDContext.create(other) if other.is_a? Hash
return false unless other.is_a? LDContext
return false unless self.kind == other.kind
return false unless self.valid? == other.valid?
return false unless self.error == other.error
return false unless self.individual_context_count == other.individual_context_count
if self.multi_kind?
self.kinds.each do |kind|
return false unless self.individual_context(kind) == other.individual_context(kind)
end
return true
end
return false unless self.key == other.key
return false unless self.name == other.name
return false unless self.anonymous == other.anonymous
return false unless self.attributes == other.attributes
# TODO(sc-227265): Calling .to_set is unnecessary once private_attributes are sets.
return false unless self.private_attributes.to_set == other.private_attributes.to_set
true
end
alias eql? ==
#
# For a single-kind context, the provided key will return the attribute value specified. This is the same as calling
# `LDCotnext.get_value`.
#
# For multi-kind contexts, the key will be interpreted as a context kind. If the multi-kind context has an
# individual context of that kind, it will be returned. Otherwise, this method will return nil. This behaves the
# same as calling `LDContext.individual_context`.
#
# @param key [Symbol, String]
#
def [](key)
return nil unless key.is_a? Symbol or key.is_a? String
multi_kind? ? individual_context(key.to_s) : get_value(key)
end
#
# Convert the LDContext to a JSON string.
#
# @param args [Array]
# @return [String]
#
def to_json(*args)
JSON.generate(to_h, *args)
end
#
# Convert the LDContext to a hash. If the LDContext is invalid, the hash will contain an error key with the error
# message.
#
# @return [Hash]
#
def to_h
return {error: error} unless valid?
return hash_single_kind unless multi_kind?
hash = {kind: 'multi'}
@contexts.each do |context|
single_kind_hash = context.to_h
kind = single_kind_hash.delete(:kind)
hash[kind] = single_kind_hash
end
hash
end
protected def hash_single_kind
hash = attributes.nil? ? {} : attributes.clone
hash[:kind] = kind
hash[:key] = key
hash[:name] = name unless name.nil?
hash[:anonymous] = anonymous if anonymous
hash[:_meta] = {privateAttributes: private_attributes} unless private_attributes.empty?
hash
end
#
# Retrieve the value of any top level, addressable attribute.
#
# This method returns an array of two values. The first element is the
# value of the requested attribute or nil if it does not exist. The second
# value will be true if the attribute exists; otherwise, it will be false.
#
# @param name [Symbol]
# @return [any]
#
private def get_top_level_addressable_attribute_single_kind(name)
case name
when :kind
kind
when :key
key
when :name
@name
when :anonymous
@anonymous
else
@attributes&.fetch(name, nil)
end
end
#
# Convenience method to create a simple single kind context providing only
# a key and kind type.
#
# @param key [String]
# @param kind [String]
#
def self.with_key(key, kind = KIND_DEFAULT)
create({key: key, kind: kind})
end
#
# Create a single kind context from the provided hash.
#
# The provided hash must match the format as outlined in the
# {https://docs.launchdarkly.com/sdk/features/user-config SDK
# documentation}.
#
# @param data [Hash]
# @return [LDContext]
#
def self.create(data)
return create_invalid_context(ERR_NOT_HASH) unless data.is_a?(Hash)
kind = data[:kind]
if kind == KIND_MULTI
contexts = []
data.each do |key, value|
next if key == :kind
contexts << create_single_context(value, key.to_s)
end
return create_multi(contexts)
end
create_single_context(data, kind)
end
#
# Create a multi-kind context from the array of LDContexts provided.
#
# A multi-kind context is comprised of two or more single kind contexts.
# You cannot include a multi-kind context instead another multi-kind
# context.
#
# Additionally, the kind of each single-kind context must be unique. For
# instance, you cannot create a multi-kind context that includes two user
# kind contexts.
#
# If you attempt to create a multi-kind context from one single-kind
# context, this method will return the single-kind context instead of a new
# multi-kind context wrapping that one single-kind.
#
# @param contexts [Array<LDContext>]
# @return [LDContext]
#
def self.create_multi(contexts)
return create_invalid_context(ERR_KIND_MULTI_NON_CONTEXT_ARRAY) unless contexts.is_a?(Array)
return create_invalid_context(ERR_KIND_MULTI_WITH_NO_KINDS) if contexts.empty?
kinds = Set.new
contexts.each do |context|
if !context.is_a?(LDContext)
return create_invalid_context(ERR_KIND_MULTI_NON_CONTEXT_ARRAY)
elsif !context.valid?
return create_invalid_context(ERR_KIND_MULTI_NON_CONTEXT_ARRAY)
elsif context.multi_kind?
return create_invalid_context(ERR_KIND_MULTI_CANNOT_CONTAIN_MULTI)
elsif kinds.include? context.kind
return create_invalid_context(ERR_KIND_MULTI_DUPLICATES)
end
kinds.add(context.kind)
end
return contexts[0] if contexts.length == 1
full_key = contexts.sort_by(&:kind)
.map { |c| LaunchDarkly::Impl::Context::canonicalize_key_for_kind(c.kind, c.key) }
.join(":")
new(nil, full_key, "multi", nil, false, nil, nil, nil, contexts)
end
#
# @param error [String]
# @return [LDContext]
#
private_class_method def self.create_invalid_context(error)
new(nil, nil, nil, nil, false, nil, nil, error)
end
#
# @param data [Hash]
# @param kind [String]
# @return [LaunchDarkly::LDContext]
#
private_class_method def self.create_single_context(data, kind)
unless data.is_a?(Hash)
return create_invalid_context(ERR_NOT_HASH)
end
kind_error = LaunchDarkly::Impl::Context.validate_kind(kind)
return create_invalid_context(kind_error) unless kind_error.nil?
key = data[:key]
key_error = LaunchDarkly::Impl::Context.validate_key(key)
return create_invalid_context(key_error) unless key_error.nil?
name = data[:name]
name_error = LaunchDarkly::Impl::Context.validate_name(name)
return create_invalid_context(name_error) unless name_error.nil?
anonymous = data.fetch(:anonymous, false)
anonymous_error = LaunchDarkly::Impl::Context.validate_anonymous(anonymous, false)
return create_invalid_context(anonymous_error) unless anonymous_error.nil?
meta = data.fetch(:_meta, {})
private_attributes = meta[:privateAttributes]
if private_attributes && !private_attributes.is_a?(Array)
return create_invalid_context(ERR_PRIVATE_NON_ARRAY)
end
# We only need to create an attribute hash if there are keys set outside
# of the ones we store in dedicated instance variables.
attributes = nil
data.each do |k, v|
case k
when :kind, :key, :name, :anonymous, :_meta
next
else
attributes ||= {}
attributes[k] = v.clone
end
end
full_key = kind == LDContext::KIND_DEFAULT ? key.to_s : LaunchDarkly::Impl::Context::canonicalize_key_for_kind(kind, key.to_s)
new(key.to_s, full_key, kind, name, anonymous, attributes, private_attributes)
end
end
end