prefab-cloud-ruby 0.13.1 → 0.13.2

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 (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'