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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +39 -0
  3. data/.tool-versions +1 -0
  4. data/CODEOWNERS +1 -0
  5. data/Gemfile +11 -4
  6. data/Gemfile.lock +52 -32
  7. data/README.md +19 -1
  8. data/Rakefile +1 -0
  9. data/VERSION +1 -1
  10. data/compile_protos.sh +3 -0
  11. data/lib/prefab/auth_interceptor.rb +1 -0
  12. data/lib/prefab/cancellable_interceptor.rb +1 -0
  13. data/lib/prefab/client.rb +51 -38
  14. data/lib/prefab/config_client.rb +145 -73
  15. data/lib/prefab/config_helper.rb +29 -0
  16. data/lib/prefab/config_loader.rb +98 -13
  17. data/lib/prefab/config_resolver.rb +56 -49
  18. data/lib/prefab/error.rb +6 -0
  19. data/lib/prefab/errors/initialization_timeout_error.rb +13 -0
  20. data/lib/prefab/errors/invalid_api_key_error.rb +19 -0
  21. data/lib/prefab/errors/missing_default_error.rb +13 -0
  22. data/lib/prefab/feature_flag_client.rb +129 -11
  23. data/lib/prefab/internal_logger.rb +29 -0
  24. data/lib/prefab/logger_client.rb +10 -8
  25. data/lib/prefab/murmer3.rb +1 -0
  26. data/lib/prefab/noop_cache.rb +1 -0
  27. data/lib/prefab/noop_stats.rb +1 -0
  28. data/lib/prefab/options.rb +82 -0
  29. data/lib/prefab/ratelimit_client.rb +1 -0
  30. data/lib/prefab-cloud-ruby.rb +10 -0
  31. data/lib/prefab_pb.rb +214 -132
  32. data/lib/prefab_services_pb.rb +35 -6
  33. data/prefab-cloud-ruby.gemspec +29 -10
  34. data/run_test_harness_server.sh +8 -0
  35. data/test/.prefab.test.config.yaml +27 -1
  36. data/test/harness_server.rb +64 -0
  37. data/test/test_client.rb +98 -0
  38. data/test/test_config_client.rb +56 -0
  39. data/test/test_config_loader.rb +39 -25
  40. data/test/test_config_resolver.rb +134 -38
  41. data/test/test_feature_flag_client.rb +277 -35
  42. data/test/test_helper.rb +70 -4
  43. data/test/test_logger.rb +23 -29
  44. metadata +69 -14
  45. 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
- feature_obj = @base_client.config_client.get(feature_name)
21
- return is_on?(feature_name, lookup_key, attributes, feature_obj)
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 is_on?(feature_name, lookup_key, attributes, feature_obj)
27
- if feature_obj.nil?
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
- attributes << lookup_key if lookup_key
32
- if (attributes & feature_obj.whitelisted).size > 0
33
- return true
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
- return get_user_pct(feature_name, lookup_key) < feature_obj.pct
88
+ percent_through_distribution = get_user_pct(feature_name, lookup_key)
38
89
  end
39
90
 
40
- return feature_obj.pct > rand()
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.account_id}#{feature}#{lookup_key}"
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
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
1
2
  module Prefab
2
3
  class LoggerClient < Logger
3
4
 
4
- SEP = ".".freeze
5
- BASE = "log_level".freeze
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) || :warn
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 = "#{path.gsub("/", SEP).gsub(".rb", "")}#{SEP}#{base_label}"
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"] || :info
146
+ def get(key, default=nil)
147
+ ENV["PREFAB_LOG_CLIENT_BOOTSTRAP_LOG_LEVEL"] || default
146
148
  end
147
149
  end
148
150
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  class Murmur3
2
3
  ## MurmurHash3 was written by Austin Appleby, and is placed in the public
3
4
  ## domain. The author hereby disclaims copyright to this source code.
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Prefab
2
3
  class NoopCache
3
4
  def fetch(name, opts, &method)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Prefab
2
3
 
3
4
  class NoopStats
@@ -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
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Prefab
2
3
  class RateLimitClient
3
4
 
@@ -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'