prefab-cloud-ruby 0

Sign up to get free protection for your applications and to get access to all the features.
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