sdk-reforge 1.9.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 (103) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc.sample +3 -0
  3. data/.github/CODEOWNERS +2 -0
  4. data/.github/pull_request_template.md +8 -0
  5. data/.github/workflows/ruby.yml +48 -0
  6. data/.gitmodules +3 -0
  7. data/.rubocop.yml +13 -0
  8. data/.tool-versions +1 -0
  9. data/CHANGELOG.md +257 -0
  10. data/CODEOWNERS +1 -0
  11. data/Gemfile +29 -0
  12. data/Gemfile.lock +182 -0
  13. data/LICENSE.txt +20 -0
  14. data/README.md +105 -0
  15. data/Rakefile +63 -0
  16. data/VERSION +1 -0
  17. data/compile_protos.sh +20 -0
  18. data/dev/allocation_stats +60 -0
  19. data/dev/benchmark +40 -0
  20. data/dev/console +12 -0
  21. data/dev/script_setup.rb +18 -0
  22. data/lib/prefab_pb.rb +77 -0
  23. data/lib/reforge/caching_http_connection.rb +95 -0
  24. data/lib/reforge/client.rb +133 -0
  25. data/lib/reforge/config_client.rb +275 -0
  26. data/lib/reforge/config_client_presenter.rb +18 -0
  27. data/lib/reforge/config_loader.rb +67 -0
  28. data/lib/reforge/config_resolver.rb +84 -0
  29. data/lib/reforge/config_value_unwrapper.rb +123 -0
  30. data/lib/reforge/config_value_wrapper.rb +18 -0
  31. data/lib/reforge/context.rb +241 -0
  32. data/lib/reforge/context_shape.rb +20 -0
  33. data/lib/reforge/context_shape_aggregator.rb +70 -0
  34. data/lib/reforge/criteria_evaluator.rb +345 -0
  35. data/lib/reforge/duration.rb +58 -0
  36. data/lib/reforge/encryption.rb +65 -0
  37. data/lib/reforge/error.rb +6 -0
  38. data/lib/reforge/errors/env_var_parse_error.rb +11 -0
  39. data/lib/reforge/errors/initialization_timeout_error.rb +12 -0
  40. data/lib/reforge/errors/invalid_sdk_key_error.rb +19 -0
  41. data/lib/reforge/errors/missing_default_error.rb +13 -0
  42. data/lib/reforge/errors/missing_env_var_error.rb +11 -0
  43. data/lib/reforge/errors/uninitialized_error.rb +13 -0
  44. data/lib/reforge/evaluation.rb +53 -0
  45. data/lib/reforge/evaluation_summary_aggregator.rb +86 -0
  46. data/lib/reforge/example_contexts_aggregator.rb +77 -0
  47. data/lib/reforge/exponential_backoff.rb +21 -0
  48. data/lib/reforge/feature_flag_client.rb +43 -0
  49. data/lib/reforge/fixed_size_hash.rb +14 -0
  50. data/lib/reforge/http_connection.rb +45 -0
  51. data/lib/reforge/internal_logger.rb +43 -0
  52. data/lib/reforge/javascript_stub.rb +99 -0
  53. data/lib/reforge/local_config_parser.rb +151 -0
  54. data/lib/reforge/murmer3.rb +50 -0
  55. data/lib/reforge/options.rb +191 -0
  56. data/lib/reforge/periodic_sync.rb +74 -0
  57. data/lib/reforge/prefab.rb +120 -0
  58. data/lib/reforge/rate_limit_cache.rb +41 -0
  59. data/lib/reforge/resolved_config_presenter.rb +86 -0
  60. data/lib/reforge/semver.rb +132 -0
  61. data/lib/reforge/sse_config_client.rb +112 -0
  62. data/lib/reforge/time_helpers.rb +7 -0
  63. data/lib/reforge/weighted_value_resolver.rb +42 -0
  64. data/lib/reforge/yaml_config_parser.rb +34 -0
  65. data/lib/reforge-sdk.rb +57 -0
  66. data/test/fixtures/datafile.json +87 -0
  67. data/test/integration_test.rb +171 -0
  68. data/test/integration_test_helpers.rb +114 -0
  69. data/test/support/common_helpers.rb +201 -0
  70. data/test/support/mock_base_client.rb +41 -0
  71. data/test/support/mock_config_client.rb +19 -0
  72. data/test/support/mock_config_loader.rb +1 -0
  73. data/test/test_caching_http_connection.rb +218 -0
  74. data/test/test_client.rb +351 -0
  75. data/test/test_config_client.rb +84 -0
  76. data/test/test_config_loader.rb +82 -0
  77. data/test/test_config_resolver.rb +502 -0
  78. data/test/test_config_value_unwrapper.rb +270 -0
  79. data/test/test_config_value_wrapper.rb +42 -0
  80. data/test/test_context.rb +271 -0
  81. data/test/test_context_shape.rb +50 -0
  82. data/test/test_context_shape_aggregator.rb +150 -0
  83. data/test/test_criteria_evaluator.rb +1180 -0
  84. data/test/test_duration.rb +37 -0
  85. data/test/test_encryption.rb +16 -0
  86. data/test/test_evaluation_summary_aggregator.rb +162 -0
  87. data/test/test_example_contexts_aggregator.rb +233 -0
  88. data/test/test_exponential_backoff.rb +18 -0
  89. data/test/test_feature_flag_client.rb +16 -0
  90. data/test/test_fixed_size_hash.rb +119 -0
  91. data/test/test_helper.rb +17 -0
  92. data/test/test_integration.rb +75 -0
  93. data/test/test_internal_logger.rb +25 -0
  94. data/test/test_javascript_stub.rb +176 -0
  95. data/test/test_local_config_parser.rb +147 -0
  96. data/test/test_logger_initialization.rb +12 -0
  97. data/test/test_options.rb +93 -0
  98. data/test/test_prefab.rb +16 -0
  99. data/test/test_rate_limit_cache.rb +44 -0
  100. data/test/test_semver.rb +108 -0
  101. data/test/test_sse_config_client.rb +211 -0
  102. data/test/test_weighted_value_resolver.rb +71 -0
  103. metadata +345 -0
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ class IntegrationTest
4
+ attr_reader :func, :input, :expected, :data, :expected_data, :aggregator, :endpoint, :test_client, :block_context
5
+
6
+ def initialize(test_data)
7
+ @global_context = test_data['contexts']['global'] if test_data['contexts']
8
+ @block_context = test_data['contexts']['block'] if test_data['contexts']
9
+ @local_context = test_data['contexts']['local'] if test_data['contexts']
10
+
11
+ @client_overrides = parse_client_overrides(test_data['client_overrides'])
12
+ @func = parse_function(test_data['function'])
13
+ @input = parse_input(test_data['input'])
14
+ @expected = parse_expected(test_data['expected'])
15
+ @type = test_data['type']
16
+ @data = test_data['data']
17
+ @expected_data = test_data['expected_data'] || []
18
+ @aggregator = test_data['aggregator']
19
+ @endpoint = test_data['endpoint']
20
+ @test_client = capture_telemetry(base_client)
21
+ end
22
+
23
+ def teardown
24
+ test_client.stop
25
+ end
26
+
27
+ def test_type
28
+ if @data
29
+ :telemetry
30
+ elsif @type == "DURATION"
31
+ :duration
32
+ elsif @input[0] && @input[0].start_with?('log-level.')
33
+ :log_level
34
+ elsif @expected[:status] == 'raise'
35
+ :raise
36
+ elsif @expected[:value].nil?
37
+ :nil
38
+ else
39
+ :simple_equality
40
+ end
41
+ end
42
+
43
+ def last_data_sent
44
+ test_client.last_data_sent
45
+ end
46
+
47
+ def last_post_result
48
+ test_client.last_post_result
49
+ end
50
+
51
+ def last_post_endpoint
52
+ test_client.last_post_endpoint
53
+ end
54
+
55
+ private
56
+
57
+ def parse_client_overrides(overrides)
58
+ Hash[
59
+ (overrides || {}).map do |(k, v)|
60
+ if k.to_s == "reforge_api_url"
61
+ [:sources, [v]]
62
+ else
63
+ [k.to_sym, v]
64
+ end
65
+ end
66
+ ]
67
+ end
68
+
69
+ def parse_function(function)
70
+ case function
71
+ when 'get_or_raise' then :get
72
+ when 'enabled' then :enabled?
73
+ else :"#{function}"
74
+ end
75
+ end
76
+
77
+ def parse_input(input)
78
+ return nil if input.nil?
79
+
80
+ if input['key']
81
+ parse_config_input(input)
82
+ elsif input['flag']
83
+ parse_ff_input(input)
84
+ end
85
+ end
86
+
87
+ def parse_config_input(input)
88
+ if !input['default'].nil?
89
+ [input['key'], input['default'], @local_context]
90
+ elsif @local_context
91
+ [input['key'], Reforge::NO_DEFAULT_PROVIDED, @local_context]
92
+ else
93
+ [input['key']]
94
+ end
95
+ end
96
+
97
+ def parse_ff_input(input)
98
+ [input['flag'], input['default'], @local_context]
99
+ end
100
+
101
+ def parse_expected(expected)
102
+ return {} if expected.nil?
103
+
104
+ {
105
+ status: expected['status'],
106
+ error: parse_error_type(expected['error']),
107
+ message: expected['message'],
108
+ value: expected['value'],
109
+ millis: expected['millis'],
110
+ }
111
+ end
112
+
113
+ def parse_error_type(error_type)
114
+ case error_type
115
+ when 'missing_default' then Reforge::Errors::MissingDefaultError
116
+ when 'initialization_timeout' then Reforge::Errors::InitializationTimeoutError
117
+ when 'unable_to_decrypt' then OpenSSL::Cipher::CipherError
118
+ when 'missing_env_var' then Reforge::Errors::MissingEnvVarError
119
+ when 'unable_to_coerce_env_var' then Reforge::Errors::EnvVarParseError
120
+ else
121
+ unless error_type.nil?
122
+ throw "Unknown error type: #{error_type}"
123
+ end
124
+ end
125
+ end
126
+
127
+ def base_client
128
+ @_base_client ||= Reforge::Client.new(base_client_options)
129
+ end
130
+
131
+ def base_client_options
132
+ @_options ||= Reforge::Options.new(**{
133
+ prefab_datasources: Reforge::Options::DATASOURCES::ALL,
134
+ sdk_key: ENV['REFORGE_INTEGRATION_TEST_SDK_KEY'],
135
+ sources: [
136
+ 'https://primary.goatsofreforge.com',
137
+ 'https://secondary.goatsofreforge.com',
138
+ ],
139
+ global_context: @global_context || {},
140
+ }.merge(@client_overrides))
141
+ end
142
+
143
+ def capture_telemetry(client)
144
+ super_method = client.method(:post)
145
+
146
+ client.define_singleton_method(:post) do |url, data|
147
+ client.instance_variable_set(:@last_data_sent, data)
148
+ client.instance_variable_set(:@last_post_endpoint, url)
149
+
150
+ result = super_method.call(url, data)
151
+
152
+ client.instance_variable_set(:@last_post_result, result)
153
+
154
+ result
155
+ end
156
+
157
+ client.define_singleton_method(:last_data_sent) do
158
+ client.instance_variable_get(:@last_data_sent)
159
+ end
160
+
161
+ client.define_singleton_method(:last_post_endpoint) do
162
+ client.instance_variable_get(:@last_post_endpoint)
163
+ end
164
+
165
+ client.define_singleton_method(:last_post_result) do
166
+ client.instance_variable_get(:@last_post_result)
167
+ end
168
+
169
+ client
170
+ end
171
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntegrationTestHelpers
4
+ SUBMODULE_PATH = 'test/shared-integration-test-data'
5
+
6
+ def self.find_integration_tests
7
+ files = find_test_files
8
+
9
+ if files.none?
10
+ raise "No integration tests found"
11
+ end
12
+
13
+ files
14
+ end
15
+
16
+ def self.find_test_files
17
+ Dir[File.join(SUBMODULE_PATH, "tests/current/**/*")]
18
+ .select { |file| file =~ /\.ya?ml$/ }
19
+ end
20
+
21
+ def self.prepare_post_data(it)
22
+ case it.aggregator
23
+ when "context_shape"
24
+ aggregator = it.test_client.context_shape_aggregator
25
+
26
+ context = Reforge::Context.new(it.data)
27
+
28
+ aggregator.push(context)
29
+
30
+ expected = it.expected_data.map do |data|
31
+ PrefabProto::ContextShape.new(
32
+ name: data["name"],
33
+ field_types: data["field_types"]
34
+ )
35
+ end
36
+
37
+ [aggregator, ->(data) { data.events[0].context_shapes.shapes }, expected]
38
+ when "evaluation_summary"
39
+ aggregator = it.test_client.evaluation_summary_aggregator
40
+
41
+ aggregator.instance_variable_set("@data", Concurrent::Hash.new)
42
+
43
+ it.data["keys"].each do |key|
44
+ it.test_client.get(key)
45
+ end
46
+
47
+ expected_data = []
48
+ it.expected_data.each do |data|
49
+ value = if data["value_type"] == "string_list"
50
+ PrefabProto::StringList.new(values: data["value"])
51
+ else
52
+ data["value"]
53
+ end
54
+ expected_data << PrefabProto::ConfigEvaluationSummary.new(
55
+ key: data["key"],
56
+ type: data["type"].to_sym,
57
+ counters: [
58
+ PrefabProto::ConfigEvaluationCounter.new(
59
+ count: data["count"],
60
+ config_id: 0,
61
+ selected_value: PrefabProto::ConfigValue.new(data["value_type"] => value),
62
+ config_row_index: data["summary"]["config_row_index"],
63
+ conditional_value_index: data["summary"]["conditional_value_index"] || 0,
64
+ weighted_value_index: data["summary"]["weighted_value_index"],
65
+ reason: :UNKNOWN
66
+ )
67
+ ]
68
+ )
69
+ end
70
+
71
+ [aggregator, ->(data) {
72
+ data.events[0].summaries.summaries.each { |e|
73
+ e.counters.each { |c|
74
+ c.config_id = 0
75
+ }
76
+ }
77
+ }, expected_data]
78
+ when "example_contexts"
79
+ aggregator = it.test_client.example_contexts_aggregator
80
+
81
+ it.data.each do |key, values|
82
+ aggregator.record(Reforge::Context.new({ key => values }))
83
+ end
84
+
85
+ expected_data = []
86
+ it.expected_data.each do |k, vs|
87
+ expected_data << PrefabProto::ExampleContext.new(
88
+ timestamp: 0,
89
+ contextSet: PrefabProto::ContextSet.new(
90
+ contexts: [
91
+ PrefabProto::Context.new(
92
+ type: k,
93
+ values: vs.each_pair.map do |key, value|
94
+ [key, Reforge::ConfigValueWrapper.wrap(value)]
95
+ end.to_h
96
+ )
97
+ ]
98
+ )
99
+ )
100
+ end
101
+ [aggregator, ->(data) { data.events[0].example_contexts.examples.each { |e| e.timestamp = 0 } }, expected_data]
102
+ else
103
+ puts "unknown aggregator #{it.aggregator}"
104
+ end
105
+ end
106
+
107
+ def self.with_block_context_maybe(context, &block)
108
+ if context
109
+ Reforge::Context.with_context(context, &block)
110
+ else
111
+ yield
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommonHelpers
4
+ require 'timecop'
5
+
6
+ def setup
7
+ $oldstderr, $stderr = $stderr, StringIO.new
8
+
9
+ $logs = StringIO.new
10
+ Reforge::Context.global_context.clear
11
+ Reforge::Context.default_context.clear
12
+ SemanticLogger.add_appender(io: $logs, filter: Reforge.log_filter)
13
+ SemanticLogger.sync!
14
+ end
15
+
16
+ def teardown
17
+ if $logs && !$logs.string.empty?
18
+ log_lines = $logs.string.split("\n").reject do |line|
19
+ line.match(/Reforge::ConfigClient -- No success loading checkpoints/)
20
+ end
21
+
22
+ if log_lines.size > 0
23
+ $logs = nil
24
+ raise "Unexpected logs. Handle logs with assert_logged\n\n#{log_lines}"
25
+ end
26
+ end
27
+
28
+ #note this skips the output check in environments like rubymine that hijack the output. Alternative is a method missing error on string
29
+
30
+ if $stderr != $oldstderr && $stderr.respond_to?(:string) && !$stderr.string.empty?
31
+ # we ignore 2.X because of the number of `instance variable @xyz not initialized` warnings
32
+ if !RUBY_VERSION.start_with?('2.')
33
+ # Filter out ld-eventsource frozen string literal warnings in Ruby 3.4+
34
+ stderr_lines = $stderr.string.split("\n").reject do |line|
35
+ line.include?('ld-eventsource') && line.include?('literal string will be frozen in the future')
36
+ end
37
+
38
+ if !stderr_lines.empty?
39
+ raise "Unexpected stderr. Handle stderr with assert_stderr\n\n#{stderr_lines.join("\n")}"
40
+ end
41
+ end
42
+ end
43
+
44
+ # Only restore stderr if we have a valid oldstderr
45
+ if $oldstderr
46
+ $stderr = $oldstderr
47
+ end
48
+
49
+ Timecop.return
50
+ end
51
+
52
+ def with_env(key, value, &block)
53
+ old_value = ENV.fetch(key, nil)
54
+
55
+ ENV[key] = value
56
+ block.call
57
+ ensure
58
+ ENV[key] = old_value
59
+ end
60
+
61
+ EFFECTIVELY_NEVER = 99_999 # we sync manually
62
+
63
+ DEFAULT_NEW_CLIENT_OPTIONS = {
64
+ prefab_datasources: Reforge::Options::DATASOURCES::LOCAL_ONLY,
65
+ collect_sync_interval: EFFECTIVELY_NEVER,
66
+ }.freeze
67
+
68
+ def new_client(overrides = {})
69
+ config = overrides.delete(:config)
70
+ project_env_id = overrides.delete(:project_env_id)
71
+
72
+ Reforge::Client.new(prefab_options(overrides)).tap do |client|
73
+ inject_config(client, config) if config
74
+
75
+ client.resolver.project_env_id = project_env_id if project_env_id
76
+ end
77
+ end
78
+
79
+ def prefab_options(overrides = {})
80
+ Reforge::Options.new(
81
+ **DEFAULT_NEW_CLIENT_OPTIONS.merge(overrides)
82
+ )
83
+ end
84
+
85
+ def string_list(values)
86
+ PrefabProto::ConfigValue.new(string_list: PrefabProto::StringList.new(values: values))
87
+ end
88
+
89
+ def inject_config(client, config)
90
+ resolver = client.config_client.instance_variable_get('@config_resolver')
91
+ store = resolver.instance_variable_get('@local_store')
92
+
93
+ Array(config).each do |c|
94
+ store[c.key] = { config: c }
95
+ end
96
+ end
97
+
98
+ def inject_project_env_id(client, project_env_id)
99
+ resolver = client.config_client.instance_variable_get('@config_resolver')
100
+ resolver.project_env_id = project_env_id
101
+ end
102
+
103
+ FakeResponse = Struct.new(:status, :body)
104
+
105
+ def wait_for(condition, max_wait: 2, sleep_time: 0.01)
106
+ wait_time = 0
107
+ while !condition.call
108
+ wait_time += sleep_time
109
+ sleep sleep_time
110
+
111
+ raise "Waited #{max_wait} seconds for the condition to be true, but it never was" if wait_time > max_wait
112
+ end
113
+ end
114
+
115
+ def wait_for_post_requests(client, max_wait: 2, sleep_time: 0.01)
116
+ # we use ivars to avoid re-mocking the post method on subsequent calls
117
+ client.instance_variable_set("@_requests", [])
118
+
119
+ if !client.instance_variable_get("@_already_faked_post")
120
+ client.define_singleton_method(:post) do |*params|
121
+ @_requests.push(params)
122
+
123
+ FakeResponse.new(200, '')
124
+ end
125
+ end
126
+
127
+ client.instance_variable_set("@_already_faked_post", true)
128
+
129
+ yield
130
+
131
+ # let the flush thread run
132
+ wait_for -> { client.instance_variable_get("@_requests").size > 0 }, max_wait: max_wait, sleep_time: sleep_time
133
+
134
+ client.instance_variable_get("@_requests")
135
+ end
136
+
137
+ def assert_summary(client, data)
138
+ raise 'Evaluation summary aggregator not enabled' unless client.evaluation_summary_aggregator
139
+
140
+ assert_equal data, client.evaluation_summary_aggregator.data
141
+ end
142
+
143
+ def assert_example_contexts(client, data)
144
+ raise 'Example contexts aggregator not enabled' unless client.example_contexts_aggregator
145
+
146
+ assert_equal data, client.example_contexts_aggregator.data
147
+ end
148
+
149
+ def weighted_values(values_and_weights, hash_by_property_name: 'user.key')
150
+ values = values_and_weights.map do |value, weight|
151
+ weighted_value(value, weight)
152
+ end
153
+
154
+ PrefabProto::WeightedValues.new(weighted_values: values, hash_by_property_name: hash_by_property_name)
155
+ end
156
+
157
+ def weighted_value(string, weight)
158
+ PrefabProto::WeightedValue.new(
159
+ value: PrefabProto::ConfigValue.new(string: string), weight: weight
160
+ )
161
+ end
162
+
163
+ def context(properties)
164
+ Reforge::Context.new(properties)
165
+ end
166
+
167
+ def assert_logged(expected)
168
+ # we do a uniq here because logging can happen in a separate thread so the
169
+ # number of times a log might happen could be slightly variable.
170
+ actuals = $logs.string.split("\n").uniq
171
+ expected.each do |expectation|
172
+ matched = false
173
+
174
+ actuals.each do |actual|
175
+ matched = true if actual.match(expectation)
176
+ end
177
+
178
+ assert(matched, "expectation: #{expectation}, got: #{actuals}")
179
+ end
180
+ # mark nil to indicate we handled it
181
+ $logs = nil
182
+ end
183
+
184
+ def assert_stderr(expected)
185
+ skip "Cannot verify stderr in current environment" unless $stderr.respond_to?(:string)
186
+ $stderr.string.split("\n").uniq.each do |line|
187
+ matched = false
188
+
189
+ expected.reject! do |expectation|
190
+ matched = true if line.include?(expectation)
191
+ end
192
+
193
+ assert(matched, "expectation: #{expected}, got: #{line}")
194
+ end
195
+
196
+ assert expected.empty?, "Expected stderr to include: #{expected}, but it did not"
197
+
198
+ # restore since we've handled it
199
+ $stderr = $oldstderr
200
+ end
201
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MockBaseClient
4
+ STAGING_ENV_ID = 1
5
+ PRODUCTION_ENV_ID = 2
6
+ TEST_ENV_ID = 3
7
+ attr_reader :namespace, :logger, :config_client, :options, :posts
8
+
9
+ def initialize(options = Reforge::Options.new)
10
+ @options = options
11
+ @namespace = namespace
12
+ @config_client = MockConfigClient.new
13
+ @posts = []
14
+ end
15
+
16
+ def instance_hash
17
+ 'mock-base-client-instance-hash'
18
+ end
19
+
20
+ def project_id
21
+ 1
22
+ end
23
+
24
+ def post(_, _)
25
+ raise 'Use wait_for_post_requests'
26
+ end
27
+
28
+ def log
29
+ @logger
30
+ end
31
+
32
+ def context_shape_aggregator; end
33
+
34
+ def evaluation_summary_aggregator; end
35
+
36
+ def example_contexts_aggregator; end
37
+
38
+ def config_value(key)
39
+ @config_values[key]
40
+ end
41
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MockConfigClient
4
+ def initialize(config_values = {})
5
+ @config_values = config_values
6
+ end
7
+
8
+ def get(key, default = nil)
9
+ @config_values.fetch(key, default)
10
+ end
11
+
12
+ def get_config(key)
13
+ PrefabProto::Config.new(value: @config_values[key], key: key)
14
+ end
15
+
16
+ def mock_this_config(key, config_value)
17
+ @config_values[key] = config_value
18
+ end
19
+ end
@@ -0,0 +1 @@
1
+ # frozen_string_literal: true