prefab-cloud-ruby 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 (91) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc.sample +3 -0
  3. data/.github/workflows/ruby.yml +46 -0
  4. data/.gitmodules +3 -0
  5. data/.rubocop.yml +13 -0
  6. data/.tool-versions +1 -0
  7. data/CHANGELOG.md +169 -0
  8. data/CODEOWNERS +1 -0
  9. data/Gemfile +26 -0
  10. data/Gemfile.lock +188 -0
  11. data/LICENSE.txt +20 -0
  12. data/README.md +94 -0
  13. data/Rakefile +50 -0
  14. data/VERSION +1 -0
  15. data/bin/console +21 -0
  16. data/compile_protos.sh +18 -0
  17. data/lib/prefab/client.rb +153 -0
  18. data/lib/prefab/config_client.rb +292 -0
  19. data/lib/prefab/config_client_presenter.rb +18 -0
  20. data/lib/prefab/config_loader.rb +84 -0
  21. data/lib/prefab/config_resolver.rb +77 -0
  22. data/lib/prefab/config_value_unwrapper.rb +115 -0
  23. data/lib/prefab/config_value_wrapper.rb +18 -0
  24. data/lib/prefab/context.rb +179 -0
  25. data/lib/prefab/context_shape.rb +20 -0
  26. data/lib/prefab/context_shape_aggregator.rb +65 -0
  27. data/lib/prefab/criteria_evaluator.rb +136 -0
  28. data/lib/prefab/encryption.rb +65 -0
  29. data/lib/prefab/error.rb +6 -0
  30. data/lib/prefab/errors/env_var_parse_error.rb +11 -0
  31. data/lib/prefab/errors/initialization_timeout_error.rb +13 -0
  32. data/lib/prefab/errors/invalid_api_key_error.rb +19 -0
  33. data/lib/prefab/errors/missing_default_error.rb +13 -0
  34. data/lib/prefab/errors/missing_env_var_error.rb +11 -0
  35. data/lib/prefab/errors/uninitialized_error.rb +13 -0
  36. data/lib/prefab/evaluation.rb +52 -0
  37. data/lib/prefab/evaluation_summary_aggregator.rb +87 -0
  38. data/lib/prefab/example_contexts_aggregator.rb +78 -0
  39. data/lib/prefab/exponential_backoff.rb +21 -0
  40. data/lib/prefab/feature_flag_client.rb +42 -0
  41. data/lib/prefab/http_connection.rb +41 -0
  42. data/lib/prefab/internal_logger.rb +16 -0
  43. data/lib/prefab/local_config_parser.rb +151 -0
  44. data/lib/prefab/log_path_aggregator.rb +69 -0
  45. data/lib/prefab/logger_client.rb +264 -0
  46. data/lib/prefab/murmer3.rb +50 -0
  47. data/lib/prefab/options.rb +208 -0
  48. data/lib/prefab/periodic_sync.rb +69 -0
  49. data/lib/prefab/prefab.rb +56 -0
  50. data/lib/prefab/rate_limit_cache.rb +41 -0
  51. data/lib/prefab/resolved_config_presenter.rb +86 -0
  52. data/lib/prefab/time_helpers.rb +7 -0
  53. data/lib/prefab/weighted_value_resolver.rb +42 -0
  54. data/lib/prefab/yaml_config_parser.rb +34 -0
  55. data/lib/prefab-cloud-ruby.rb +57 -0
  56. data/lib/prefab_pb.rb +93 -0
  57. data/prefab-cloud-ruby.gemspec +155 -0
  58. data/test/.prefab.default.config.yaml +2 -0
  59. data/test/.prefab.unit_tests.config.yaml +28 -0
  60. data/test/integration_test.rb +150 -0
  61. data/test/integration_test_helpers.rb +151 -0
  62. data/test/support/common_helpers.rb +180 -0
  63. data/test/support/mock_base_client.rb +42 -0
  64. data/test/support/mock_config_client.rb +19 -0
  65. data/test/support/mock_config_loader.rb +1 -0
  66. data/test/test_client.rb +444 -0
  67. data/test/test_config_client.rb +109 -0
  68. data/test/test_config_loader.rb +117 -0
  69. data/test/test_config_resolver.rb +430 -0
  70. data/test/test_config_value_unwrapper.rb +224 -0
  71. data/test/test_config_value_wrapper.rb +42 -0
  72. data/test/test_context.rb +203 -0
  73. data/test/test_context_shape.rb +50 -0
  74. data/test/test_context_shape_aggregator.rb +147 -0
  75. data/test/test_criteria_evaluator.rb +726 -0
  76. data/test/test_encryption.rb +16 -0
  77. data/test/test_evaluation_summary_aggregator.rb +162 -0
  78. data/test/test_example_contexts_aggregator.rb +238 -0
  79. data/test/test_exponential_backoff.rb +18 -0
  80. data/test/test_feature_flag_client.rb +48 -0
  81. data/test/test_helper.rb +17 -0
  82. data/test/test_integration.rb +58 -0
  83. data/test/test_local_config_parser.rb +147 -0
  84. data/test/test_log_path_aggregator.rb +62 -0
  85. data/test/test_logger.rb +621 -0
  86. data/test/test_logger_initialization.rb +12 -0
  87. data/test/test_options.rb +75 -0
  88. data/test/test_prefab.rb +12 -0
  89. data/test/test_rate_limit_cache.rb +44 -0
  90. data/test/test_weighted_value_resolver.rb +71 -0
  91. metadata +337 -0
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ class IntegrationTest
4
+ attr_reader :func, :input, :expected, :data, :expected_data, :aggregator, :endpoint, :test_client
5
+
6
+ def initialize(test_data)
7
+ @client_overrides = parse_client_overrides(test_data['client_overrides'])
8
+ @func = parse_function(test_data['function'])
9
+ @input = parse_input(test_data['input'])
10
+ @expected = parse_expected(test_data['expected'])
11
+ @data = test_data['data']
12
+ @expected_data = test_data['expected_data'] || []
13
+ @aggregator = test_data['aggregator']
14
+ @endpoint = test_data['endpoint']
15
+ @test_client = capture_telemetry(base_client)
16
+ end
17
+
18
+ def test_type
19
+ if @data
20
+ :telemetry
21
+ elsif @input[0] && @input[0].start_with?('log-level.')
22
+ :log_level
23
+ elsif @expected[:status] == 'raise'
24
+ :raise
25
+ elsif @expected[:value].nil?
26
+ :nil
27
+ else
28
+ :simple_equality
29
+ end
30
+ end
31
+
32
+ def last_data_sent
33
+ test_client.last_data_sent
34
+ end
35
+
36
+ def last_post_result
37
+ test_client.last_post_result
38
+ end
39
+
40
+ def last_post_endpoint
41
+ test_client.last_post_endpoint
42
+ end
43
+
44
+ private
45
+
46
+ def parse_client_overrides(overrides)
47
+ Hash[
48
+ (overrides || {}).map do |(k, v)|
49
+ [k.to_sym, v]
50
+ end
51
+ ]
52
+ end
53
+
54
+ def parse_function(function)
55
+ case function
56
+ when 'get_or_raise' then :get
57
+ when 'enabled' then :enabled?
58
+ else :"#{function}"
59
+ end
60
+ end
61
+
62
+ def parse_input(input)
63
+ return nil if input.nil?
64
+
65
+ if input['key']
66
+ parse_config_input(input)
67
+ elsif input['flag']
68
+ parse_ff_input(input)
69
+ end
70
+ end
71
+
72
+ def parse_config_input(input)
73
+ if !input['default'].nil?
74
+ [input['key'], input['default']]
75
+ else
76
+ [input['key']]
77
+ end
78
+ end
79
+
80
+ def parse_ff_input(input)
81
+ [input['flag'], input['default'], input['context']]
82
+ end
83
+
84
+ def parse_expected(expected)
85
+ return {} if expected.nil?
86
+
87
+ {
88
+ status: expected['status'],
89
+ error: parse_error_type(expected['error']),
90
+ message: expected['message'],
91
+ value: expected['value']
92
+ }
93
+ end
94
+
95
+ def parse_error_type(error_type)
96
+ case error_type
97
+ when 'missing_default' then Prefab::Errors::MissingDefaultError
98
+ when 'initialization_timeout' then Prefab::Errors::InitializationTimeoutError
99
+ when 'unable_to_decrypt' then OpenSSL::Cipher::CipherError
100
+ when 'missing_env_var' then Prefab::Errors::MissingEnvVarError
101
+ when 'unable_to_coerce_env_var' then Prefab::Errors::EnvVarParseError
102
+ else
103
+ unless error_type.nil?
104
+ throw "Unknown error type: #{error_type}"
105
+ end
106
+ end
107
+ end
108
+
109
+ def base_client
110
+ @_base_client ||= Prefab::Client.new(base_client_options)
111
+ end
112
+
113
+ def base_client_options
114
+ @_options ||= Prefab::Options.new(**{
115
+ prefab_config_override_dir: 'none',
116
+ prefab_config_classpath_dir: 'test',
117
+ prefab_envs: ['unit_tests'],
118
+ prefab_datasources: Prefab::Options::DATASOURCES::ALL,
119
+ api_key: ENV['PREFAB_INTEGRATION_TEST_API_KEY'],
120
+ prefab_api_url: 'https://api.staging-prefab.cloud',
121
+ }.merge(@client_overrides))
122
+ end
123
+
124
+ def capture_telemetry(client)
125
+ client.define_singleton_method(:post) do |url, data|
126
+ client.instance_variable_set(:@last_data_sent, data)
127
+ client.instance_variable_set(:@last_post_endpoint, url)
128
+
129
+ result = super(url, data)
130
+
131
+ client.instance_variable_set(:@last_post_result, result)
132
+
133
+ result
134
+ end
135
+
136
+ client.define_singleton_method(:last_data_sent) do
137
+ client.instance_variable_get(:@last_data_sent)
138
+ end
139
+
140
+ client.define_singleton_method(:last_post_endpoint) do
141
+ client.instance_variable_get(:@last_post_endpoint)
142
+ end
143
+
144
+ client.define_singleton_method(:last_post_result) do
145
+ client.instance_variable_get(:@last_post_result)
146
+ end
147
+
148
+ client
149
+ end
150
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IntegrationTestHelpers
4
+ SUBMODULE_PATH = 'test/prefab-cloud-integration-test-data'
5
+ RAISE_IF_NO_TESTS_FOUND = ENV['PREFAB_INTEGRATION_TEST_RAISE'] == 'true'
6
+
7
+ def self.find_integration_tests
8
+ version = find_integration_test_version
9
+
10
+ files = find_versioned_test_files(version)
11
+
12
+ if files.none?
13
+ message = "No integration tests found for version: #{version}"
14
+ raise message if RAISE_IF_NO_TESTS_FOUND
15
+
16
+ puts message
17
+ end
18
+
19
+ files
20
+ end
21
+
22
+ def self.find_integration_test_version
23
+ File.read(File.join(SUBMODULE_PATH, 'version')).strip
24
+ rescue StandardError => e
25
+ puts "No version found for integration tests: #{e.message}"
26
+ end
27
+
28
+ def self.find_versioned_test_files(version)
29
+ if version.nil?
30
+ []
31
+ else
32
+ Dir[File.join(SUBMODULE_PATH, "tests/#{version}/**/*")]
33
+ .select { |file| file =~ /\.ya?ml$/ }
34
+ end
35
+ end
36
+
37
+ SEVERITY_LOOKUP = Prefab::LogPathAggregator::SEVERITY_KEY.invert
38
+
39
+ def self.prepare_post_data(it)
40
+ case it.aggregator
41
+ when "log_path"
42
+ aggregator = it.test_client.log_path_aggregator
43
+
44
+ it.data.each do |data|
45
+ data['counts'].each_pair do |severity, count|
46
+ count.times { aggregator.push(data['logger_name'], SEVERITY_LOOKUP[severity]) }
47
+ end
48
+ end
49
+
50
+ expected_loggers = Hash.new { |h, k| h[k] = PrefabProto::Logger.new }
51
+
52
+ it.expected_data.each do |data|
53
+ data["counts"].each do |(severity, count)|
54
+ expected_loggers[data["logger_name"]][severity] = count
55
+ expected_loggers[data["logger_name"]]["logger_name"] = data["logger_name"]
56
+ end
57
+ end
58
+
59
+ [aggregator, ->(data) { data.loggers }, expected_loggers.values]
60
+ when "context_shape"
61
+ aggregator = it.test_client.context_shape_aggregator
62
+
63
+ context = Prefab::Context.new(it.data)
64
+
65
+ aggregator.push(context)
66
+
67
+ expected = it.expected_data.map do |data|
68
+ PrefabProto::ContextShape.new(
69
+ name: data["name"],
70
+ field_types: data["field_types"]
71
+ )
72
+ end
73
+
74
+ [aggregator, ->(data) { data.shapes }, expected]
75
+ when "evaluation_summary"
76
+ aggregator = it.test_client.evaluation_summary_aggregator
77
+
78
+ aggregator.instance_variable_set("@data", Concurrent::Hash.new)
79
+
80
+ it.data.each do |key|
81
+ it.test_client.get(key)
82
+ end
83
+
84
+ expected_data = []
85
+ it.expected_data.each do |data|
86
+ value = if data["value_type"] == "string_list"
87
+ PrefabProto::StringList.new(values: data["value"])
88
+ else
89
+ data["value"]
90
+ end
91
+ expected_data << PrefabProto::ConfigEvaluationSummary.new(
92
+ key: data["key"],
93
+ type: data["type"].to_sym,
94
+ counters: [
95
+ PrefabProto::ConfigEvaluationCounter.new(
96
+ count: data["count"],
97
+ config_id: 0,
98
+ selected_value: PrefabProto::ConfigValue.new(data["value_type"] => value),
99
+ config_row_index: data["summary"]["config_row_index"],
100
+ conditional_value_index: data["summary"]["conditional_value_index"] || 0,
101
+ weighted_value_index: data["summary"]["weighted_value_index"],
102
+ reason: :UNKNOWN
103
+ )
104
+ ]
105
+ )
106
+ end
107
+
108
+ [aggregator, ->(data) {
109
+ data.events[0].summaries.summaries.each { |e|
110
+ e.counters.each { |c|
111
+ c.config_id = 0
112
+ }
113
+ }
114
+ }, expected_data]
115
+ when "example_contexts"
116
+ aggregator = it.test_client.example_contexts_aggregator
117
+
118
+ it.data.each do |key, values|
119
+ aggregator.record(Prefab::Context.new({ key => values }))
120
+ end
121
+
122
+ expected_data = []
123
+ it.expected_data.each do |k, vs|
124
+ expected_data << PrefabProto::ExampleContext.new(
125
+ timestamp: 0,
126
+ contextSet: PrefabProto::ContextSet.new(
127
+ contexts: [
128
+ PrefabProto::Context.new(
129
+ type: k,
130
+ values: vs.each_pair.map do |key, value|
131
+ [key, Prefab::ConfigValueWrapper.wrap(value)]
132
+ end.to_h
133
+ )
134
+ ]
135
+ )
136
+ )
137
+ end
138
+ [aggregator, ->(data) { data.events[0].example_contexts.examples.each { |e| e.timestamp = 0 } }, expected_data]
139
+ else
140
+ puts "unknown aggregator #{it.aggregator}"
141
+ end
142
+ end
143
+
144
+ def self.with_parent_context_maybe(context, &block)
145
+ if context
146
+ Prefab::Context.with_context(context, &block)
147
+ else
148
+ yield
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,180 @@
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 = nil
10
+ Timecop.freeze('2023-08-09 15:18:12 -0400')
11
+ end
12
+
13
+ def teardown
14
+ if $logs && !$logs.string.empty?
15
+ raise "Unexpected logs. Handle logs with assert_only_expected_logs or assert_logged\n\n#{$logs.string}"
16
+ end
17
+
18
+ if $stderr != $oldstderr && !$stderr.string.empty?
19
+ # we ignore 2.X because of the number of `instance variable @xyz not initialized` warnings
20
+ if !RUBY_VERSION.start_with?('2.')
21
+ raise "Unexpected stderr. Handle stderr with assert_stderr\n\n#{$stderr.string}"
22
+ end
23
+ end
24
+
25
+ $stderr = $oldstderr
26
+
27
+ Timecop.return
28
+ end
29
+
30
+ def with_env(key, value, &block)
31
+ old_value = ENV.fetch(key, nil)
32
+
33
+ ENV[key] = value
34
+ block.call
35
+ ensure
36
+ ENV[key] = old_value
37
+ end
38
+
39
+ DEFAULT_NEW_CLIENT_OPTIONS = {
40
+ prefab_config_override_dir: 'none',
41
+ prefab_config_classpath_dir: 'test',
42
+ prefab_envs: ['unit_tests'],
43
+ prefab_datasources: Prefab::Options::DATASOURCES::LOCAL_ONLY
44
+ }.freeze
45
+
46
+ def new_client(overrides = {})
47
+
48
+ config = overrides.delete(:config)
49
+ project_env_id = overrides.delete(:project_env_id)
50
+
51
+ Prefab::Client.new(prefab_options(overrides)).tap do |client|
52
+ inject_config(client, config) if config
53
+
54
+ client.resolver.project_env_id = project_env_id if project_env_id
55
+ end
56
+ end
57
+
58
+ def prefab_options(overrides = {})
59
+ $logs ||= StringIO.new
60
+ Prefab::Options.new(
61
+ **DEFAULT_NEW_CLIENT_OPTIONS.merge(
62
+ overrides.merge(logdev: $logs)
63
+ )
64
+ )
65
+ end
66
+
67
+ def string_list(values)
68
+ PrefabProto::ConfigValue.new(string_list: PrefabProto::StringList.new(values: values))
69
+ end
70
+
71
+ def inject_config(client, config)
72
+ resolver = client.config_client.instance_variable_get('@config_resolver')
73
+ store = resolver.instance_variable_get('@local_store')
74
+
75
+ Array(config).each do |c|
76
+ store[c.key] = { config: c }
77
+ end
78
+ end
79
+
80
+ def inject_project_env_id(client, project_env_id)
81
+ resolver = client.config_client.instance_variable_get('@config_resolver')
82
+ resolver.project_env_id = project_env_id
83
+ end
84
+
85
+ FakeResponse = Struct.new(:status, :body)
86
+
87
+ def wait_for(condition, max_wait: 2, sleep_time: 0.01)
88
+ wait_time = 0
89
+ while !condition.call
90
+ wait_time += sleep_time
91
+ sleep sleep_time
92
+
93
+ raise "Waited #{max_wait} seconds for the condition to be true, but it never was" if wait_time > max_wait
94
+ end
95
+ end
96
+
97
+ def wait_for_post_requests(client, max_wait: 2, sleep_time: 0.01)
98
+ # we use ivars to avoid re-mocking the post method on subsequent calls
99
+ client.instance_variable_set("@_requests", [])
100
+
101
+ if !client.instance_variable_get("@_already_faked_post")
102
+ client.define_singleton_method(:post) do |*params|
103
+ @_requests.push(params)
104
+
105
+ FakeResponse.new(200, '')
106
+ end
107
+ end
108
+
109
+ client.instance_variable_set("@_already_faked_post", true)
110
+
111
+ yield
112
+
113
+ # let the flush thread run
114
+ wait_for -> { client.instance_variable_get("@_requests").size > 0 }, max_wait: max_wait, sleep_time: sleep_time
115
+
116
+ client.instance_variable_get("@_requests")
117
+ end
118
+
119
+ def assert_summary(client, data)
120
+ raise 'Evaluation summary aggregator not enabled' unless client.evaluation_summary_aggregator
121
+
122
+ assert_equal data, client.evaluation_summary_aggregator.data
123
+ end
124
+
125
+ def assert_example_contexts(client, data)
126
+ raise 'Example contexts aggregator not enabled' unless client.example_contexts_aggregator
127
+
128
+ assert_equal data, client.example_contexts_aggregator.data
129
+ end
130
+
131
+ def weighted_values(values_and_weights, hash_by_property_name: 'user.key')
132
+ values = values_and_weights.map do |value, weight|
133
+ weighted_value(value, weight)
134
+ end
135
+
136
+ PrefabProto::WeightedValues.new(weighted_values: values, hash_by_property_name: hash_by_property_name)
137
+ end
138
+
139
+ def weighted_value(string, weight)
140
+ PrefabProto::WeightedValue.new(
141
+ value: PrefabProto::ConfigValue.new(string: string), weight: weight
142
+ )
143
+ end
144
+
145
+ def context(properties)
146
+ Prefab::Context.new(properties)
147
+ end
148
+
149
+ def assert_only_expected_logs
150
+ assert_equal "WARN 2023-08-09 15:18:12 -0400: cloud.prefab.client.configclient No success loading checkpoints\n", $logs.string
151
+ # mark nil to indicate we handled it
152
+ $logs = nil
153
+ end
154
+
155
+ def assert_logged(expected)
156
+ # we do a uniq here because logging can happen in a separate thread so the
157
+ # number of times a log might happen could be slightly variable.
158
+ assert_equal expected, $logs.string.split("\n").uniq
159
+ # mark nil to indicate we handled it
160
+ $logs = nil
161
+ end
162
+
163
+ def assert_stderr(expected)
164
+ assert ($stderr.string.split("\n").uniq & expected).size > 0
165
+
166
+ # Ruby 2.X has a lot of warnings about instance variables not being
167
+ # initialized so we don't try to assert on stderr for those versions.
168
+ # Instead we just stop after asserting that our expected errors are
169
+ # included in the output.
170
+ if RUBY_VERSION.start_with?('2.')
171
+ puts $stderr.string
172
+ return
173
+ end
174
+
175
+ assert_equal expected, $stderr.string.split("\n")
176
+
177
+ # restore since we've handled it
178
+ $stderr = $oldstderr
179
+ end
180
+ end
@@ -0,0 +1,42 @@
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 = Prefab::Options.new)
10
+ @options = options
11
+ @namespace = namespace
12
+ @config_client = MockConfigClient.new
13
+ Prefab::LoggerClient.new(options.logdev)
14
+ @posts = []
15
+ end
16
+
17
+ def instance_hash
18
+ 'mock-base-client-instance-hash'
19
+ end
20
+
21
+ def project_id
22
+ 1
23
+ end
24
+
25
+ def post(_, _)
26
+ raise 'Use wait_for_post_requests'
27
+ end
28
+
29
+ def log
30
+ @logger
31
+ end
32
+
33
+ def context_shape_aggregator; end
34
+
35
+ def evaluation_summary_aggregator; end
36
+
37
+ def example_contexts_aggregator; end
38
+
39
+ def config_value(key)
40
+ @config_values[key]
41
+ end
42
+ 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