prefab-cloud-ruby 0.13.1 → 0.13.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +39 -0
- data/.tool-versions +1 -0
- data/CODEOWNERS +1 -0
- data/Gemfile +11 -4
- data/Gemfile.lock +52 -32
- data/README.md +19 -1
- data/Rakefile +1 -0
- data/VERSION +1 -1
- data/compile_protos.sh +3 -0
- data/lib/prefab/auth_interceptor.rb +1 -0
- data/lib/prefab/cancellable_interceptor.rb +1 -0
- data/lib/prefab/client.rb +51 -38
- data/lib/prefab/config_client.rb +145 -73
- data/lib/prefab/config_helper.rb +29 -0
- data/lib/prefab/config_loader.rb +98 -13
- data/lib/prefab/config_resolver.rb +56 -49
- data/lib/prefab/error.rb +6 -0
- data/lib/prefab/errors/initialization_timeout_error.rb +13 -0
- data/lib/prefab/errors/invalid_api_key_error.rb +19 -0
- data/lib/prefab/errors/missing_default_error.rb +13 -0
- data/lib/prefab/feature_flag_client.rb +129 -11
- data/lib/prefab/internal_logger.rb +29 -0
- data/lib/prefab/logger_client.rb +10 -8
- data/lib/prefab/murmer3.rb +1 -0
- data/lib/prefab/noop_cache.rb +1 -0
- data/lib/prefab/noop_stats.rb +1 -0
- data/lib/prefab/options.rb +82 -0
- data/lib/prefab/ratelimit_client.rb +1 -0
- data/lib/prefab-cloud-ruby.rb +10 -0
- data/lib/prefab_pb.rb +214 -132
- data/lib/prefab_services_pb.rb +35 -6
- data/prefab-cloud-ruby.gemspec +29 -10
- data/run_test_harness_server.sh +8 -0
- data/test/.prefab.test.config.yaml +27 -1
- data/test/harness_server.rb +64 -0
- data/test/test_client.rb +98 -0
- data/test/test_config_client.rb +56 -0
- data/test/test_config_loader.rb +39 -25
- data/test/test_config_resolver.rb +134 -38
- data/test/test_feature_flag_client.rb +277 -35
- data/test/test_helper.rb +70 -4
- data/test/test_logger.rb +23 -29
- metadata +69 -14
- data/.ruby-version +0 -1
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Prefab
|
4
|
+
module Errors
|
5
|
+
class InvalidApiKeyError < Prefab::Error
|
6
|
+
def initialize(key)
|
7
|
+
if key.nil? || key.empty?
|
8
|
+
message = "No API key. Set PREFAB_API_KEY env var or use PREFAB_DATASOURCES=LOCAL_ONLY"
|
9
|
+
|
10
|
+
super(message)
|
11
|
+
else
|
12
|
+
message = "Your API key format is invalid. Expecting something like 123-development-yourapikey-SDK. You provided `#{key}`"
|
13
|
+
|
14
|
+
super(message)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Prefab
|
4
|
+
module Errors
|
5
|
+
class MissingDefaultError < Prefab::Error
|
6
|
+
def initialize(key)
|
7
|
+
message = "No value found for key '#{key}' and no default was provided.\n\nIf you'd prefer returning `nil` rather than raising when this occurs, modify the `on_no_default` value you provide in your Prefab::Options."
|
8
|
+
|
9
|
+
super(message)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -1,5 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
module Prefab
|
2
3
|
class FeatureFlagClient
|
4
|
+
include Prefab::ConfigHelper
|
3
5
|
MAX_32_FLOAT = 4294967294.0
|
4
6
|
|
5
7
|
def initialize(base_client)
|
@@ -14,38 +16,154 @@ module Prefab
|
|
14
16
|
feature_is_on_for?(feature_name, nil)
|
15
17
|
end
|
16
18
|
|
17
|
-
def feature_is_on_for?(feature_name, lookup_key, attributes:
|
19
|
+
def feature_is_on_for?(feature_name, lookup_key, attributes: {})
|
18
20
|
@base_client.stats.increment("prefab.featureflag.on", tags: ["feature:#{feature_name}"])
|
19
21
|
|
20
|
-
|
21
|
-
|
22
|
+
return is_on?(_get(feature_name, lookup_key, attributes, default: false))
|
23
|
+
end
|
24
|
+
|
25
|
+
def get(feature_name, lookup_key=nil, attributes={}, default: false)
|
26
|
+
variant = _get(feature_name, lookup_key, attributes, default: default)
|
27
|
+
|
28
|
+
value_of_variant_or_nil(variant, default)
|
22
29
|
end
|
23
30
|
|
24
31
|
private
|
25
32
|
|
26
|
-
def
|
27
|
-
if
|
33
|
+
def value_of_variant_or_nil(variant_maybe, default)
|
34
|
+
if variant_maybe.nil?
|
35
|
+
default != Prefab::Client::NO_DEFAULT_PROVIDED ? default : nil
|
36
|
+
else
|
37
|
+
value_of_variant(variant_maybe)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def _get(feature_name, lookup_key=nil, attributes={}, default:)
|
42
|
+
feature_obj = @base_client.config_client.get(feature_name, default)
|
43
|
+
config_obj = @base_client.config_client.get_config_obj(feature_name)
|
44
|
+
|
45
|
+
return nil if feature_obj.nil? || config_obj.nil?
|
46
|
+
|
47
|
+
if feature_obj == !!feature_obj
|
48
|
+
return feature_obj
|
49
|
+
end
|
50
|
+
|
51
|
+
variants = config_obj.variants
|
52
|
+
get_variant(feature_name, lookup_key, attributes, feature_obj, variants)
|
53
|
+
end
|
54
|
+
|
55
|
+
def is_on?(variant)
|
56
|
+
if variant.nil?
|
28
57
|
return false
|
29
58
|
end
|
30
59
|
|
31
|
-
|
32
|
-
|
33
|
-
|
60
|
+
if variant == !!variant
|
61
|
+
return variant
|
62
|
+
end
|
63
|
+
|
64
|
+
variant.bool
|
65
|
+
rescue
|
66
|
+
@base_client.log.info("is_on? methods only work for boolean feature flags variants. This feature flags variant is '#{variant}'. Returning false")
|
67
|
+
false
|
68
|
+
end
|
69
|
+
|
70
|
+
def get_variant(feature_name, lookup_key, attributes, feature_obj, variants)
|
71
|
+
if !feature_obj.active
|
72
|
+
return get_variant_obj(variants, feature_obj.inactive_variant_idx)
|
34
73
|
end
|
35
74
|
|
75
|
+
#default to inactive
|
76
|
+
variant_weights = [Prefab::VariantWeight.new(variant_idx: feature_obj.inactive_variant_idx, weight: 1)]
|
77
|
+
|
78
|
+
# if rules.match
|
79
|
+
feature_obj.rules.each do |rule|
|
80
|
+
if criteria_match?(rule.criteria, lookup_key, attributes)
|
81
|
+
variant_weights = rule.variant_weights
|
82
|
+
break
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
percent_through_distribution = rand()
|
36
87
|
if lookup_key
|
37
|
-
|
88
|
+
percent_through_distribution = get_user_pct(feature_name, lookup_key)
|
38
89
|
end
|
39
90
|
|
40
|
-
|
91
|
+
variant_idx = get_variant_idx_from_weights(variant_weights, percent_through_distribution, feature_name)
|
92
|
+
|
93
|
+
return get_variant_obj(variants, variant_idx)
|
94
|
+
end
|
95
|
+
|
96
|
+
def get_variant_obj(variants, idx)
|
97
|
+
# our array is 0 based, but the idx are 1 based so the protos are clearly set
|
98
|
+
return variants[idx - 1] if variants.length >= idx
|
99
|
+
nil
|
100
|
+
end
|
101
|
+
|
102
|
+
def get_variant_idx_from_weights(variant_weights, percent_through_distribution, feature_name)
|
103
|
+
distrubution_space = variant_weights.inject(0) { |sum, v| sum + v.weight }
|
104
|
+
bucket = distrubution_space * percent_through_distribution
|
105
|
+
sum = 0
|
106
|
+
variant_weights.each do |variant_weight|
|
107
|
+
if bucket < sum + variant_weight.weight
|
108
|
+
return variant_weight.variant_idx
|
109
|
+
else
|
110
|
+
sum += variant_weight.weight
|
111
|
+
end
|
112
|
+
end
|
113
|
+
# variants didn't add up to 100%
|
114
|
+
@base_client.log.info("Variants of #{feature_name} did not add to 100%")
|
115
|
+
return variant_weights.last.variant_idx
|
41
116
|
end
|
42
117
|
|
43
118
|
def get_user_pct(feature, lookup_key)
|
44
|
-
to_hash = "#{@base_client.
|
119
|
+
to_hash = "#{@base_client.project_id}#{feature}#{lookup_key}"
|
45
120
|
int_value = Murmur3.murmur3_32(to_hash)
|
46
121
|
int_value / MAX_32_FLOAT
|
47
122
|
end
|
48
123
|
|
124
|
+
# def criteria_match?(rule, lookup_key, attributes)
|
125
|
+
#
|
126
|
+
# end
|
127
|
+
def criteria_match?(criteria, lookup_key, attributes)
|
128
|
+
if criteria.operator == :ALWAYS_TRUE
|
129
|
+
return true
|
130
|
+
elsif criteria.operator == :LOOKUP_KEY_IN
|
131
|
+
return criteria.values.include?(lookup_key)
|
132
|
+
elsif criteria.operator == :LOOKUP_KEY_NOT_IN
|
133
|
+
return !criteria.values.include?(lookup_key)
|
134
|
+
elsif criteria.operator == :IN_SEG
|
135
|
+
return segment_matches(criteria.values, lookup_key, attributes).any?
|
136
|
+
elsif criteria.operator == :NOT_IN_SEG
|
137
|
+
return segment_matches(criteria.values, lookup_key, attributes).none?
|
138
|
+
elsif criteria.operator == :PROP_IS_ONE_OF
|
139
|
+
return criteria.values.include?(attributes[criteria.property]) || criteria.values.include?(attributes[criteria.property.to_sym])
|
140
|
+
elsif criteria.operator == :PROP_IS_NOT_ONE_OF
|
141
|
+
return !(criteria.values.include?(attributes[criteria.property]) || criteria.values.include?(attributes[criteria.property.to_sym]))
|
142
|
+
end
|
143
|
+
@base_client.log.info("Unknown Operator")
|
144
|
+
false
|
145
|
+
end
|
146
|
+
|
147
|
+
# evaluate each segment key and return whether each one matches
|
148
|
+
# there should be an associated segment available as a standard config obj
|
149
|
+
def segment_matches(segment_keys, lookup_key, attributes)
|
150
|
+
segment_keys.map do |segment_key|
|
151
|
+
segment = @base_client.config_client.get(segment_key)
|
152
|
+
if segment.nil?
|
153
|
+
@base_client.log.info("Missing Segment")
|
154
|
+
false
|
155
|
+
else
|
156
|
+
segment_match?(segment, lookup_key, attributes)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# does a given segment match?
|
162
|
+
def segment_match?(segment, lookup_key, attributes)
|
163
|
+
segment.criterion.map do |criteria|
|
164
|
+
criteria_match?(criteria, lookup_key, attributes)
|
165
|
+
end.any?
|
166
|
+
end
|
49
167
|
end
|
50
168
|
end
|
51
169
|
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Prefab
|
3
|
+
class InternalLogger < Logger
|
4
|
+
def initialize(path, logger)
|
5
|
+
@path = path
|
6
|
+
@logger = logger
|
7
|
+
end
|
8
|
+
|
9
|
+
def debug(progname = nil, &block)
|
10
|
+
@logger.log_internal yield, @path, progname, DEBUG
|
11
|
+
end
|
12
|
+
|
13
|
+
def info(progname = nil, &block)
|
14
|
+
@logger.log_internal yield, @path, progname, INFO
|
15
|
+
end
|
16
|
+
|
17
|
+
def warn(progname = nil, &block)
|
18
|
+
@logger.log_internal yield, @path, progname, WARN
|
19
|
+
end
|
20
|
+
|
21
|
+
def error(progname = nil, &block)
|
22
|
+
@logger.log_internal yield, @path, progname, ERROR
|
23
|
+
end
|
24
|
+
|
25
|
+
def fatal(progname = nil, &block)
|
26
|
+
@logger.log_internal yield, @path, progname, FATAL
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/prefab/logger_client.rb
CHANGED
@@ -1,8 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
module Prefab
|
2
3
|
class LoggerClient < Logger
|
3
4
|
|
4
|
-
SEP = "."
|
5
|
-
BASE = "log_level"
|
5
|
+
SEP = "."
|
6
|
+
BASE = "log_level"
|
7
|
+
UNKNOWN = "unknown"
|
6
8
|
|
7
9
|
def initialize(logdev, formatter: nil)
|
8
10
|
super(logdev)
|
@@ -107,10 +109,10 @@ module Prefab
|
|
107
109
|
|
108
110
|
# Find the closest match to 'log_level.path' in config
|
109
111
|
def level_of(path)
|
110
|
-
closest_log_level_match = @config_client.get(BASE
|
112
|
+
closest_log_level_match = @config_client.get(BASE, :warn)
|
111
113
|
path.split(SEP).inject([BASE]) do |memo, n|
|
112
114
|
memo << n
|
113
|
-
val = @config_client.get(memo.join(SEP))
|
115
|
+
val = @config_client.get(memo.join(SEP), nil)
|
114
116
|
unless val.nil?
|
115
117
|
closest_log_level_match = val
|
116
118
|
end
|
@@ -122,12 +124,12 @@ module Prefab
|
|
122
124
|
# sanitize & clean the path of the caller so the key
|
123
125
|
# looks like log_level.app.models.user
|
124
126
|
def get_path(absolute_path, base_label)
|
125
|
-
path = absolute_path
|
127
|
+
path = (absolute_path || UNKNOWN).dup
|
126
128
|
path.slice! Dir.pwd
|
127
129
|
|
128
130
|
path.gsub!(/.*?(?=\/lib\/)/im, "")
|
129
131
|
|
130
|
-
path =
|
132
|
+
path = path.gsub("/", SEP).gsub(".rb", "") + SEP + base_label
|
131
133
|
path.slice! ".lib"
|
132
134
|
path.slice! SEP
|
133
135
|
path
|
@@ -141,8 +143,8 @@ module Prefab
|
|
141
143
|
# StubConfigClient to be used while config client initializes
|
142
144
|
# since it may log
|
143
145
|
class BootstrappingConfigClient
|
144
|
-
def get(key)
|
145
|
-
ENV["PREFAB_LOG_CLIENT_BOOTSTRAP_LOG_LEVEL"] ||
|
146
|
+
def get(key, default=nil)
|
147
|
+
ENV["PREFAB_LOG_CLIENT_BOOTSTRAP_LOG_LEVEL"] || default
|
146
148
|
end
|
147
149
|
end
|
148
150
|
end
|
data/lib/prefab/murmer3.rb
CHANGED
data/lib/prefab/noop_cache.rb
CHANGED
data/lib/prefab/noop_stats.rb
CHANGED
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Prefab
|
3
|
+
class Options
|
4
|
+
attr_reader :api_key
|
5
|
+
attr_reader :logdev
|
6
|
+
attr_reader :log_formatter
|
7
|
+
attr_reader :stats
|
8
|
+
attr_reader :shared_cache
|
9
|
+
attr_reader :namespace
|
10
|
+
attr_reader :prefab_api_url
|
11
|
+
attr_reader :prefab_grpc_url
|
12
|
+
attr_reader :on_no_default
|
13
|
+
attr_reader :initialization_timeout_sec
|
14
|
+
attr_reader :on_init_failure
|
15
|
+
attr_reader :prefab_config_override_dir
|
16
|
+
attr_reader :prefab_config_classpath_dir
|
17
|
+
attr_reader :defaults_env
|
18
|
+
|
19
|
+
DEFAULT_LOG_FORMATTER = proc { |severity, datetime, progname, msg|
|
20
|
+
"#{severity.ljust(5)} #{datetime}: #{progname} #{msg}\n"
|
21
|
+
}
|
22
|
+
|
23
|
+
module ON_INITIALIZATION_FAILURE
|
24
|
+
RAISE = 1
|
25
|
+
RETURN = 2
|
26
|
+
end
|
27
|
+
module ON_NO_DEFAULT
|
28
|
+
RAISE = 1
|
29
|
+
RETURN_NIL = 2
|
30
|
+
end
|
31
|
+
module DATASOURCES
|
32
|
+
ALL = 1
|
33
|
+
LOCAL_ONLY = 2
|
34
|
+
end
|
35
|
+
|
36
|
+
def initialize(
|
37
|
+
api_key: ENV['PREFAB_API_KEY'],
|
38
|
+
logdev: $stdout,
|
39
|
+
stats: NoopStats.new, # receives increment("prefab.limitcheck", {:tags=>["policy_group:page_view", "pass:true"]})
|
40
|
+
shared_cache: NoopCache.new, # Something that quacks like Rails.cache ideally memcached
|
41
|
+
namespace: "",
|
42
|
+
log_formatter: DEFAULT_LOG_FORMATTER,
|
43
|
+
prefab_api_url: ENV["PREFAB_API_URL"] || 'https://api.prefab.cloud',
|
44
|
+
prefab_grpc_url: ENV["PREFAB_GRPC_URL"] || 'grpc.prefab.cloud:443',
|
45
|
+
on_no_default: ON_NO_DEFAULT::RAISE, # options :raise, :warn_and_return_nil,
|
46
|
+
initialization_timeout_sec: 10, # how long to wait before on_init_failure
|
47
|
+
on_init_failure: ON_INITIALIZATION_FAILURE::RAISE, #options :unlock_and_continue, :lock_and_keep_trying, :raise
|
48
|
+
# new_config_callback: nil, #callback method
|
49
|
+
# live_override_url: nil,
|
50
|
+
prefab_datasources: ENV['PREFAB_DATASOURCES'] == "LOCAL_ONLY" ? DATASOURCES::LOCAL_ONLY : DATASOURCES::ALL,
|
51
|
+
prefab_config_override_dir: Dir.home,
|
52
|
+
prefab_config_classpath_dir: ".",
|
53
|
+
defaults_env: ""
|
54
|
+
)
|
55
|
+
# debugger
|
56
|
+
@api_key = api_key
|
57
|
+
@logdev = logdev
|
58
|
+
@stats = stats
|
59
|
+
@shared_cache = shared_cache
|
60
|
+
@namespace = namespace
|
61
|
+
@log_formatter = log_formatter
|
62
|
+
@prefab_api_url = prefab_api_url
|
63
|
+
@prefab_grpc_url = prefab_grpc_url
|
64
|
+
@on_no_default = on_no_default
|
65
|
+
@initialization_timeout_sec = initialization_timeout_sec
|
66
|
+
@on_init_failure = on_init_failure
|
67
|
+
@prefab_datasources = prefab_datasources
|
68
|
+
@prefab_config_classpath_dir = prefab_config_classpath_dir
|
69
|
+
@prefab_config_override_dir = prefab_config_override_dir
|
70
|
+
@defaults_env = defaults_env
|
71
|
+
end
|
72
|
+
|
73
|
+
def local_only?
|
74
|
+
@prefab_datasources == DATASOURCES::LOCAL_ONLY
|
75
|
+
end
|
76
|
+
|
77
|
+
# https://api.prefab.cloud -> https://api-prefab-cloud.global.ssl.fastly.net
|
78
|
+
def url_for_api_cdn
|
79
|
+
ENV['PREFAB_CDN_URL'] || "#{@prefab_api_url.gsub(/\./, "-")}.global.ssl.fastly.net"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
data/lib/prefab-cloud-ruby.rb
CHANGED
@@ -1,9 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
require "concurrent/atomics"
|
2
3
|
require 'concurrent'
|
3
4
|
require 'faraday'
|
4
5
|
require 'openssl'
|
6
|
+
require 'openssl'
|
7
|
+
require 'ld-eventsource'
|
5
8
|
require 'prefab_pb'
|
9
|
+
require 'prefab/error'
|
10
|
+
require 'prefab/errors/initialization_timeout_error'
|
11
|
+
require 'prefab/errors/invalid_api_key_error'
|
12
|
+
require 'prefab/errors/missing_default_error'
|
6
13
|
require 'prefab_services_pb'
|
14
|
+
require 'prefab/options'
|
15
|
+
require 'prefab/internal_logger'
|
16
|
+
require 'prefab/config_helper'
|
7
17
|
require 'prefab/config_loader'
|
8
18
|
require 'prefab/config_resolver'
|
9
19
|
require 'prefab/client'
|