flagsmith 2.0.0 → 3.0.0

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +4 -1
  4. data/.rubocop_todo.yml +0 -1
  5. data/.ruby-version +1 -0
  6. data/Gemfile.lock +35 -6
  7. data/LICENCE +4 -5
  8. data/README.md +8 -64
  9. data/Rakefile +5 -1
  10. data/example/.env.development +5 -0
  11. data/example/.env.test +4 -0
  12. data/example/.gitignore +5 -0
  13. data/example/.hanamirc +3 -0
  14. data/example/.rspec +2 -0
  15. data/example/Gemfile +25 -0
  16. data/example/Gemfile.lock +269 -0
  17. data/example/README.md +29 -0
  18. data/example/Rakefile +9 -0
  19. data/example/apps/web/application.rb +162 -0
  20. data/example/apps/web/config/routes.rb +1 -0
  21. data/example/apps/web/controllers/.gitkeep +0 -0
  22. data/example/apps/web/controllers/home/index.rb +32 -0
  23. data/example/apps/web/templates/application.html.slim +7 -0
  24. data/example/apps/web/templates/home/index.html.slim +10 -0
  25. data/example/apps/web/views/application_layout.rb +7 -0
  26. data/example/apps/web/views/home/index.rb +29 -0
  27. data/example/config/boot.rb +2 -0
  28. data/example/config/environment.rb +17 -0
  29. data/example/config/initializers/.gitkeep +0 -0
  30. data/example/config/initializers/flagsmith.rb +9 -0
  31. data/example/config/puma.rb +15 -0
  32. data/example/config.ru +3 -0
  33. data/example/spec/example/entities/.gitkeep +0 -0
  34. data/example/spec/example/mailers/.gitkeep +0 -0
  35. data/example/spec/example/repositories/.gitkeep +0 -0
  36. data/example/spec/features_helper.rb +12 -0
  37. data/example/spec/spec_helper.rb +103 -0
  38. data/example/spec/support/.gitkeep +0 -0
  39. data/example/spec/support/capybara.rb +8 -0
  40. data/example/spec/web/controllers/.gitkeep +0 -0
  41. data/example/spec/web/controllers/home/index_spec.rb +9 -0
  42. data/example/spec/web/features/.gitkeep +0 -0
  43. data/example/spec/web/views/application_layout_spec.rb +10 -0
  44. data/example/spec/web/views/home/index_spec.rb +10 -0
  45. data/lib/flagsmith/engine/core.rb +88 -0
  46. data/lib/flagsmith/engine/environments/models.rb +61 -0
  47. data/lib/flagsmith/engine/features/models.rb +173 -0
  48. data/lib/flagsmith/engine/identities/models.rb +115 -0
  49. data/lib/flagsmith/engine/organisations/models.rb +28 -0
  50. data/lib/flagsmith/engine/projects/models.rb +31 -0
  51. data/lib/flagsmith/engine/segments/constants.rb +41 -0
  52. data/lib/flagsmith/engine/segments/evaluator.rb +68 -0
  53. data/lib/flagsmith/engine/segments/models.rb +121 -0
  54. data/lib/flagsmith/engine/utils/hash_func.rb +34 -0
  55. data/lib/flagsmith/hash_slice.rb +12 -0
  56. data/lib/flagsmith/sdk/analytics_processor.rb +39 -0
  57. data/lib/flagsmith/sdk/api_client.rb +47 -0
  58. data/lib/flagsmith/sdk/config.rb +91 -0
  59. data/lib/flagsmith/sdk/errors.rb +9 -0
  60. data/lib/flagsmith/sdk/instance_methods.rb +137 -0
  61. data/lib/flagsmith/sdk/intervals.rb +24 -0
  62. data/lib/flagsmith/sdk/models/flag.rb +62 -0
  63. data/lib/flagsmith/sdk/models/flags/collection.rb +105 -0
  64. data/lib/flagsmith/sdk/pooling_manager.rb +31 -0
  65. data/lib/flagsmith/version.rb +5 -0
  66. data/lib/flagsmith.rb +79 -101
  67. metadata +104 -6
  68. data/.gitignore +0 -57
  69. data/flagsmith.gemspec +0 -22
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../utils/hash_func'
4
+
5
+ module Flagsmith
6
+ module Engine
7
+ # FeatureStateModel
8
+ class FeatureState
9
+ include Flagsmith::Engine::Utils::HashFunc
10
+
11
+ attr_reader :feature, :enabled, :django_id, :uuid, :feature_segment
12
+ attr_accessor :multivariate_feature_state_values
13
+
14
+ def initialize(params = {})
15
+ @feature = params.fetch(:feature)
16
+ @enabled = params.fetch(:enabled)
17
+ @feature_segment = params.fetch(:feature_segment, nil)
18
+ @django_id = params.fetch(:django_id, nil)
19
+ @feature_state_value = params.fetch(:feature_state_value, nil)
20
+ @uuid = params.fetch(:uuid, SecureRandom.uuid)
21
+ @multivariate_feature_state_values = params.fetch(:multivariate_feature_state_values, [])
22
+ end
23
+
24
+ attr_writer :feature_state_value
25
+
26
+ def get_value(identity_id = nil)
27
+ return multivariate_value(identity_id) if identity_id && multivariate_feature_state_values.length.positive?
28
+
29
+ @feature_state_value
30
+ end
31
+
32
+ alias set_value feature_state_value=
33
+ alias feature_state_uuid uuid
34
+ alias enabled? enabled
35
+
36
+ def multivariate_value(identity_id)
37
+ percentage_value = hashed_percentage_for_object_ids([django_id || uuid, identity_id])
38
+
39
+ start_percentage = 0
40
+ multivariate_feature_state_values.sort.each do |multi_fs_value|
41
+ limit = multi_fs_value.percentage_allocation + start_percentage
42
+
43
+ if start_percentage <= percentage_value && percentage_value < limit
44
+ return multi_fs_value.multivariate_feature_option.value
45
+ end
46
+
47
+ start_percentage = limit
48
+ end
49
+ @feature_state_value
50
+ end
51
+
52
+ # Returns `true` if `self` is higher segment priority than `other`
53
+ # (i.e. has lower value for feature_segment.priority)
54
+ # NOTE:
55
+ # A segment will be considered higher priority only if:
56
+ # 1. `other` does not have a feature segment
57
+ # (i.e: it is an environment feature state or it's a
58
+ # feature state with feature segment but from an old document
59
+ # that does not have `feature_segment.priority`)
60
+ # but `self` does.
61
+ # 2. `other` have a feature segment with high priority
62
+ def higher_segment_priority?(other)
63
+ feature_segment.priority.to_i < (other&.feature_segment&.priority || Float::INFINITY)
64
+ rescue TypeError, NoMethodError
65
+ false
66
+ end
67
+
68
+ class << self
69
+ def build(json)
70
+ multivariate_feature_state_values = build_multivariate_values(json[:multivariate_feature_state_values])
71
+ attributes = json.slice(:uuid, :enabled, :django_id, :feature_state_value)
72
+ .merge(feature: Flagsmith::Engine::Feature.build(json[:feature]))
73
+ .merge(multivariate_feature_state_values: multivariate_feature_state_values)
74
+ if json.key?(:feature_segment) && !json[:feature_segment].nil?
75
+ attributes = attributes.merge(
76
+ feature_segment: Flagsmith::Engine::Features::Segment.new(json[:feature_segment])
77
+ )
78
+ end
79
+ new(**attributes)
80
+ end
81
+
82
+ def build_multivariate_values(multivariate_feature_state_values)
83
+ return [] unless multivariate_feature_state_values&.any?
84
+
85
+ multivariate_feature_state_values.map do |fsv|
86
+ Flagsmith::Engine::Features::MultivariateStateValue.build(fsv)
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ # FeatureModel
93
+ class Feature
94
+ attr_reader :id, :name, :type
95
+
96
+ def initialize(id:, name:, type:)
97
+ @id = id
98
+ @name = name
99
+ @type = type
100
+ end
101
+
102
+ def ==(other)
103
+ return false if other.nil?
104
+
105
+ id == other.id
106
+ end
107
+
108
+ class << self
109
+ def build(json)
110
+ new(**json.slice(:id, :name, :type))
111
+ end
112
+ end
113
+ end
114
+
115
+ module Features
116
+ # MultivariateFeatureOptionModel
117
+ class MultivariateOption
118
+ attr_reader :value, :id
119
+
120
+ def initialize(value:, id: nil)
121
+ @value = value
122
+ @id = id
123
+ end
124
+
125
+ class << self
126
+ def build(json)
127
+ new(**json.slice(:id, :value))
128
+ end
129
+ end
130
+ end
131
+
132
+ # MultivariateFeatureStateValueModel
133
+ class MultivariateStateValue
134
+ attr_reader :id, :multivariate_feature_option, :percentage_allocation, :mv_fs_value_uuid
135
+
136
+ def initialize(id:, multivariate_feature_option:, percentage_allocation:, mv_fs_value_uuid: SecureRandom.uuid)
137
+ @id = id
138
+ @percentage_allocation = percentage_allocation
139
+ @multivariate_feature_option = multivariate_feature_option
140
+ @mv_fs_value_uuid = mv_fs_value_uuid
141
+ end
142
+
143
+ def <=>(other)
144
+ return false if other.nil?
145
+
146
+ if !id.nil? && !other.id.nil?
147
+ id - other.id
148
+ else
149
+ mv_fs_value_uuid <=> other.mv_fs_value_uuid
150
+ end
151
+ end
152
+
153
+ class << self
154
+ def build(json)
155
+ new(
156
+ **json.slice(:id, :percentage_allocation, :mv_fs_value_uuid)
157
+ .merge(multivariate_feature_option: MultivariateOption.build(json[:multivariate_feature_option]))
158
+ )
159
+ end
160
+ end
161
+ end
162
+
163
+ # FeatureSegment
164
+ class Segment
165
+ attr_reader :priority
166
+
167
+ def initialize(params = {})
168
+ @priority = params[:priority].nil? ? nil : params[:priority].to_i
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ module Engine
5
+ # IdentityModel
6
+ class Identity
7
+ attr_reader :identifier, :environment_api_key, :created_date, :identity_features,
8
+ :identity_traits, :identity_uuid, :django_id
9
+
10
+ def initialize(params)
11
+ @identity_uuid = params.fetch(:identity_uuid, SecureRandom.uuid)
12
+ @created_date = params[:created_date].is_a?(String) ? Date.parse(params[:created_date]) : params[:created_date]
13
+ @identity_traits = params.fetch(:identity_traits, [])
14
+ @identity_features = params.fetch(:identity_features, Flagsmith::Engine::Identities::FeaturesList.new)
15
+ @environment_api_key = params.fetch(:environment_api_key)
16
+ @identifier = params.fetch(:identifier)
17
+ @django_id = params.fetch(:django_id, nil)
18
+ end
19
+
20
+ def composite_key
21
+ Identity.generate_composite_key(@environment_api_key, @identifier)
22
+ end
23
+
24
+ def update_traits(traits)
25
+ existing_traits = {}
26
+ @identity_traits.each { |trait| existing_traits[trait.key] = trait }
27
+
28
+ traits.each do |trait|
29
+ if trait.value.nil?
30
+ existing_traits.delete(trait.key)
31
+ else
32
+ existing_traits[trait.key] = trait
33
+ end
34
+ end
35
+
36
+ @identity_traits = existing_traits.values
37
+ end
38
+
39
+ class << self
40
+ def generate_composite_key(env_key, identifier)
41
+ "#{env_key}_#{identifier}"
42
+ end
43
+
44
+ def build(json)
45
+ identity_features = Flagsmith::Engine::Identities::FeaturesList.build(json[:identity_features])
46
+ identity_traits = json.fetch(:identity_traits, [])
47
+ .map { |t| Flagsmith::Engine::Identities::Trait.build(t) }
48
+
49
+ Identity.new(
50
+ **json.slice(:identifier, :identity_uuid, :environment_api_key, :created_date, :django_id)
51
+ .merge(identity_features: identity_features, identity_traits: identity_traits)
52
+ )
53
+ end
54
+ end
55
+ end
56
+
57
+ module Identities
58
+ # TraitModel
59
+ class Trait
60
+ attr_reader :trait_value, :trait_key
61
+
62
+ def initialize(trait_key:, trait_value:)
63
+ @trait_key = trait_key
64
+ @trait_value = trait_value
65
+ end
66
+
67
+ alias key trait_key
68
+ alias value trait_value
69
+
70
+ class << self
71
+ def build(json)
72
+ new(**json.slice(:trait_key, :trait_value))
73
+ end
74
+ end
75
+ end
76
+
77
+ class NotUniqueFeatureState < StandardError; end
78
+
79
+ # IdentityFeaturesList
80
+ class FeaturesList
81
+ include Enumerable
82
+
83
+ def initialize(list = [])
84
+ @list = []
85
+ list.each { |item| @list << item }
86
+ end
87
+
88
+ def <<(item)
89
+ @list.each do |v|
90
+ next unless v.django_id == item.django_id
91
+
92
+ raise NotUniqueFeatureState, "Feature state for this feature already exists, django_id: #{django_id}"
93
+ end
94
+ @list << item
95
+ end
96
+
97
+ alias push <<
98
+
99
+ def each(&block)
100
+ @list.each { |item| block&.call(item) || item }
101
+ end
102
+
103
+ class << self
104
+ def build(identity_features)
105
+ return new unless identity_features&.any?
106
+
107
+ new(
108
+ identity_features.map { |f| Flagsmith::Engine::FeatureState.build(f) }
109
+ )
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ module Engine
5
+ # OrganisationModel
6
+ class Organisation
7
+ attr_reader :id, :name, :feature_analitycs, :stop_serving_flags, :persist_trait_data
8
+
9
+ def initialize(id:, name:, stop_serving_flags:, persist_trait_data:, feature_analitycs: nil)
10
+ @id = id
11
+ @name = name
12
+ @feature_analitycs = feature_analitycs
13
+ @stop_serving_flags = stop_serving_flags
14
+ @persist_trait_data = persist_trait_data
15
+ end
16
+
17
+ def unique_slug
18
+ "#{id}-#{name}"
19
+ end
20
+
21
+ class << self
22
+ def build(json)
23
+ new(**json.slice(:id, :name, :feature_analitycs, :stop_serving_flags, :persist_trait_data))
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ module Engine
5
+ # ProjectModel
6
+ class Project
7
+ attr_reader :id, :name, :organisation
8
+ attr_accessor :segments, :hide_disabled_flags
9
+
10
+ def initialize(id:, name:, organisation:, hide_disabled_flags:, segments: [])
11
+ @id = id
12
+ @name = name
13
+ @hide_disabled_flags = hide_disabled_flags
14
+ @organisation = organisation
15
+ @segments = segments
16
+ end
17
+
18
+ class << self
19
+ def build(json)
20
+ segments = json.fetch(:segments, []).map { |s| Flagsmith::Engine::Segment.build(s) }
21
+
22
+ new(
23
+ **json.slice(:id, :name, :hide_disabled_flags)
24
+ .merge(organisation: Flagsmith::Engine::Organisation.build(json[:organisation]))
25
+ .merge(segments: segments)
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ module Engine
5
+ module Segments
6
+ module Constants
7
+ # Segment Rules
8
+ ALL_RULE = 'ALL'
9
+ ANY_RULE = 'ANY'
10
+ NONE_RULE = 'NONE'
11
+
12
+ RULE_TYPES = [ALL_RULE, ANY_RULE, NONE_RULE].freeze
13
+
14
+ # Segment Condition Operators
15
+ EQUAL = 'EQUAL'
16
+ GREATER_THAN = 'GREATER_THAN'
17
+ LESS_THAN = 'LESS_THAN'
18
+ LESS_THAN_INCLUSIVE = 'LESS_THAN_INCLUSIVE'
19
+ CONTAINS = 'CONTAINS'
20
+ GREATER_THAN_INCLUSIVE = 'GREATER_THAN_INCLUSIVE'
21
+ NOT_CONTAINS = 'NOT_CONTAINS'
22
+ NOT_EQUAL = 'NOT_EQUAL'
23
+ REGEX = 'REGEX'
24
+ PERCENTAGE_SPLIT = 'PERCENTAGE_SPLIT'
25
+
26
+ CONDITION_OPERATORS = [
27
+ EQUAL,
28
+ GREATER_THAN,
29
+ LESS_THAN,
30
+ LESS_THAN_INCLUSIVE,
31
+ CONTAINS,
32
+ GREATER_THAN_INCLUSIVE,
33
+ NOT_CONTAINS,
34
+ NOT_EQUAL,
35
+ REGEX,
36
+ PERCENTAGE_SPLIT
37
+ ].freeze
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'constants'
4
+ require_relative '../utils/hash_func'
5
+
6
+ module Flagsmith
7
+ module Engine
8
+ module Segments
9
+ # Evaluator methods
10
+ module Evaluator
11
+ include Flagsmith::Engine::Segments::Constants
12
+ include Flagsmith::Engine::Utils::HashFunc
13
+
14
+ def get_identity_segments(environment, identity, override_traits = nil)
15
+ environment.project.segments.select do |s|
16
+ evaluate_identity_in_segment(identity, s, override_traits)
17
+ end
18
+ end
19
+
20
+ # Evaluates whether a given identity is in the provided segment.
21
+ #
22
+ # :param identity: identity model object to evaluate
23
+ # :param segment: segment model object to evaluate
24
+ # :param override_traits: pass in a list of traits to use instead of those on the
25
+ # identity model itself
26
+ # :return: True if the identity is in the segment, False otherwise
27
+ def evaluate_identity_in_segment(identity, segment, override_traits = nil)
28
+ segment.rules&.length&.positive? &&
29
+ segment.rules.all? do |rule|
30
+ traits_match_segment_rule(
31
+ override_traits || identity.identity_traits,
32
+ rule,
33
+ segment.id,
34
+ identity.django_id || identity.composite_key
35
+ )
36
+ end
37
+ end
38
+
39
+ def traits_match_segment_rule(identity_traits, rule, segment_id, identity_id)
40
+ matching_block = lambda { |condition|
41
+ traits_match_segment_condition(identity_traits, condition, segment_id, identity_id)
42
+ }
43
+
44
+ matches_conditions =
45
+ if rule.conditions&.length&.positive?
46
+ rule.conditions.send(rule.matching_function, &matching_block)
47
+ else true
48
+ end
49
+
50
+ matches_conditions &&
51
+ rule.rules.all? { |r| traits_match_segment_rule(identity_traits, r, segment_id, identity_id) }
52
+ end
53
+
54
+ def traits_match_segment_condition(identity_traits, condition, segment_id, identity_id)
55
+ if condition.operator == PERCENTAGE_SPLIT
56
+ return hashed_percentage_for_object_ids([segment_id, identity_id]) <= condition.value.to_f
57
+ end
58
+
59
+ trait = identity_traits.find { |t| t.key == condition.property }
60
+
61
+ return condition.match_trait_value?(trait.value) if trait
62
+
63
+ false
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'constants'
4
+
5
+ module Flagsmith
6
+ module Engine
7
+ # SegmentModel
8
+ class Segment
9
+ attr_reader :id, :name
10
+ attr_accessor :rules, :feature_states
11
+
12
+ def initialize(id:, name:, rules: [], feature_states: [])
13
+ @id = id
14
+ @name = name
15
+ @rules = rules
16
+ @feature_states = feature_states
17
+ end
18
+
19
+ class << self
20
+ def build(json)
21
+ feature_states = json.fetch(:feature_states, []).map { |fs| Flagsmith::Engine::FeatureState.build(fs) }
22
+ rules = json.fetch(:rules, []).map { |rule| Flagsmith::Engine::Segments::Rule.build(rule) }
23
+
24
+ new(
25
+ **json.slice(:id, :name)
26
+ .merge(feature_states: feature_states, rules: rules)
27
+ )
28
+ end
29
+ end
30
+ end
31
+
32
+ module Segments
33
+ # SegmentConditionModel
34
+ class Condition
35
+ include Constants
36
+ attr_reader :operator, :value, :property
37
+
38
+ MATCHING_FUNCTIONS = {
39
+ EQUAL => ->(other_value, self_value) { other_value == self_value },
40
+ GREATER_THAN => ->(other_value, self_value) { other_value > self_value },
41
+ GREATER_THAN_INCLUSIVE => ->(other_value, self_value) { other_value >= self_value },
42
+ LESS_THAN => ->(other_value, self_value) { other_value < self_value },
43
+ LESS_THAN_INCLUSIVE => ->(other_value, self_value) { other_value <= self_value },
44
+ NOT_EQUAL => ->(other_value, self_value) { other_value != self_value },
45
+ CONTAINS => ->(other_value, self_value) { other_value.include? self_value },
46
+
47
+ NOT_CONTAINS => ->(other_value, self_value) { !other_value.include? self_value },
48
+ REGEX => ->(other_value, self_value) { other_value.match? self_value }
49
+ }.freeze
50
+
51
+ def initialize(operator:, value:, property: nil)
52
+ @operator = operator
53
+ @value = value
54
+ @property = property
55
+ end
56
+
57
+ def match_trait_value?(trait_value)
58
+ if @value.is_a?(String) && @value.match?(/:semver$/)
59
+ trait_value = Semantic::Version.new(trait_value.gsub(/:semver$/, ''))
60
+ end
61
+
62
+ type_as_trait_value = format_to_type_of(trait_value)
63
+ formatted_value = type_as_trait_value ? type_as_trait_value.call(@value) : @value
64
+
65
+ MATCHING_FUNCTIONS[operator]&.call(trait_value, formatted_value)
66
+ end
67
+
68
+ # rubocop:disable Metrics/AbcSize
69
+ def format_to_type_of(input)
70
+ {
71
+ 'String' => ->(v) { v.to_s },
72
+ 'Semantic::Version' => ->(v) { Semantic::Version.new(v.to_s.gsub(/:semver$/, '')) },
73
+ 'TrueClass' => ->(v) { ['True', 'true', 'TRUE', true, 1, '1'].include?(v) ? true : false },
74
+ 'FalseClass' => ->(v) { ['False', 'false', 'FALSE', false, 0, '0'].include?(v) ? false : true },
75
+ 'Integer' => ->(v) { v.to_i },
76
+ 'Float' => ->(v) { v.to_f }
77
+ }[input.class.to_s]
78
+ end
79
+ # rubocop:enable Metrics/AbcSize
80
+
81
+ class << self
82
+ def build(json)
83
+ new(**json.slice(:operator, :value).merge(property: json[:property_]))
84
+ end
85
+ end
86
+ end
87
+
88
+ # SegmentRuleModel
89
+ class Rule
90
+ include Constants
91
+ MATCHING_FUNCTIONS = {
92
+ ANY_RULE => :any?,
93
+ ALL_RULE => :all?,
94
+ NONE_RULE => :none?
95
+ }.freeze
96
+
97
+ attr_accessor :type, :rules, :conditions
98
+
99
+ def initialize(type: nil, rules: [], conditions: [])
100
+ @type = type
101
+ @rules = rules
102
+ @conditions = conditions
103
+ end
104
+
105
+ def matching_function
106
+ MATCHING_FUNCTIONS[type]
107
+ end
108
+
109
+ class << self
110
+ def build(json)
111
+ rules = json.fetch(:rules, []).map { |r| Flagsmith::Engine::Segments::Rule.build(r) }
112
+ conditions = json.fetch(:conditions, []).map { |c| Flagsmith::Engine::Segments::Condition.build(c) }
113
+ new(
114
+ type: json[:type], rules: rules, conditions: conditions
115
+ )
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Flagsmith
6
+ module Engine
7
+ module Utils
8
+ # HashFunction
9
+ module HashFunc
10
+ # Given a list of object ids, get a floating point number between 0 (inclusive) and
11
+ # 100 (exclusive) based on the hash of those ids. This should give the same value
12
+ # every time for any list of ids.
13
+ #
14
+ # :param object_ids: list of object ids to calculate the hash for
15
+ # :param iterations: num times to include each id in the generated string to hash
16
+ # :return: (float) number between 0 (inclusive) and 100 (exclusive)
17
+ def hashed_percentage_for_object_ids(object_ids, iterations = 1)
18
+ to_hash = (object_ids.map(&:to_s) * iterations).flatten.join(',')
19
+
20
+ hashed_value = Digest::MD5.hexdigest(to_hash.encode('utf-8'))
21
+ hashed_value_as_int = hashed_value.to_i(16)
22
+ value = ((hashed_value_as_int % 9999).to_f / 9998) * 100
23
+
24
+ # since we want a number between 0 (inclusive) and 100 (exclusive), in the
25
+ # unlikely case that we get the exact number 100, we call the method again
26
+ # and increase the number of iterations to ensure we get a different result
27
+ return hashed_percentage_for_object_ids(object_ids, iterations + 1) if value == 100
28
+
29
+ value
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ # Hash#slice was added in ruby version 2.5
5
+ module HashSlice
6
+ def slice(*keys)
7
+ select { |key, _value| keys.include?(key) }
8
+ end
9
+ end
10
+ end
11
+
12
+ Hash.include Flagsmith::HashSlice if RUBY_VERSION < '2.5.0'
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ # Used to control how often we send data(in seconds)
5
+ class AnalyticsProcessor
6
+ ENDPOINT = 'analytics/flags/'
7
+ TIMER = 10
8
+ attr_reader :last_flushed, :timeout, :analytics_data
9
+
10
+ # AnalyticsProcessor is used to track how often individual Flags are evaluated within
11
+ # the Flagsmith SDK. Docs: https://docs.flagsmith.com/advanced-use/flag-analytics.
12
+ #
13
+ # data[:environment_key] environment key obtained from the Flagsmith UI
14
+ # data[:base_api_url] base api url to override when using self hosted version
15
+ # data[:timeout] used to tell requests to stop waiting for a response after a
16
+ # given number of seconds
17
+ def initialize(data)
18
+ @last_flushed = Time.now
19
+ @analytics_data = {}
20
+ @api_client = data.fetch(:api_client)
21
+ @timeout = data.fetch(:timeout, 3)
22
+ end
23
+
24
+ # Sends all the collected data to the api asynchronously and resets the timer
25
+ def flush
26
+ return if @analytics_data.empty?
27
+
28
+ @api_client.post(ENDPOINT, @analytics_data.to_json)
29
+
30
+ @analytics_data = {}
31
+ @last_flushed = Time.now
32
+ end
33
+
34
+ def track_feature(feature_id)
35
+ @analytics_data[feature_id] = @analytics_data.fetch(feature_id, 0) + 1
36
+ flush if (Time.now - @last_flushed) > TIMER * 1000
37
+ end
38
+ end
39
+ end