flagsmith 2.0.0 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +4 -1
  4. data/.rubocop_todo.yml +0 -1
  5. data/.ruby-version +1 -0
  6. data/Gemfile.lock +35 -6
  7. data/LICENCE +4 -5
  8. data/README.md +8 -64
  9. data/Rakefile +5 -1
  10. data/example/.env.development +5 -0
  11. data/example/.env.test +4 -0
  12. data/example/.gitignore +5 -0
  13. data/example/.hanamirc +3 -0
  14. data/example/.rspec +2 -0
  15. data/example/Gemfile +25 -0
  16. data/example/Gemfile.lock +269 -0
  17. data/example/README.md +29 -0
  18. data/example/Rakefile +9 -0
  19. data/example/apps/web/application.rb +162 -0
  20. data/example/apps/web/config/routes.rb +1 -0
  21. data/example/apps/web/controllers/.gitkeep +0 -0
  22. data/example/apps/web/controllers/home/index.rb +32 -0
  23. data/example/apps/web/templates/application.html.slim +7 -0
  24. data/example/apps/web/templates/home/index.html.slim +10 -0
  25. data/example/apps/web/views/application_layout.rb +7 -0
  26. data/example/apps/web/views/home/index.rb +29 -0
  27. data/example/config/boot.rb +2 -0
  28. data/example/config/environment.rb +17 -0
  29. data/example/config/initializers/.gitkeep +0 -0
  30. data/example/config/initializers/flagsmith.rb +9 -0
  31. data/example/config/puma.rb +15 -0
  32. data/example/config.ru +3 -0
  33. data/example/spec/example/entities/.gitkeep +0 -0
  34. data/example/spec/example/mailers/.gitkeep +0 -0
  35. data/example/spec/example/repositories/.gitkeep +0 -0
  36. data/example/spec/features_helper.rb +12 -0
  37. data/example/spec/spec_helper.rb +103 -0
  38. data/example/spec/support/.gitkeep +0 -0
  39. data/example/spec/support/capybara.rb +8 -0
  40. data/example/spec/web/controllers/.gitkeep +0 -0
  41. data/example/spec/web/controllers/home/index_spec.rb +9 -0
  42. data/example/spec/web/features/.gitkeep +0 -0
  43. data/example/spec/web/views/application_layout_spec.rb +10 -0
  44. data/example/spec/web/views/home/index_spec.rb +10 -0
  45. data/lib/flagsmith/engine/core.rb +88 -0
  46. data/lib/flagsmith/engine/environments/models.rb +61 -0
  47. data/lib/flagsmith/engine/features/models.rb +173 -0
  48. data/lib/flagsmith/engine/identities/models.rb +115 -0
  49. data/lib/flagsmith/engine/organisations/models.rb +28 -0
  50. data/lib/flagsmith/engine/projects/models.rb +31 -0
  51. data/lib/flagsmith/engine/segments/constants.rb +41 -0
  52. data/lib/flagsmith/engine/segments/evaluator.rb +68 -0
  53. data/lib/flagsmith/engine/segments/models.rb +121 -0
  54. data/lib/flagsmith/engine/utils/hash_func.rb +34 -0
  55. data/lib/flagsmith/hash_slice.rb +12 -0
  56. data/lib/flagsmith/sdk/analytics_processor.rb +39 -0
  57. data/lib/flagsmith/sdk/api_client.rb +47 -0
  58. data/lib/flagsmith/sdk/config.rb +91 -0
  59. data/lib/flagsmith/sdk/errors.rb +9 -0
  60. data/lib/flagsmith/sdk/instance_methods.rb +137 -0
  61. data/lib/flagsmith/sdk/intervals.rb +24 -0
  62. data/lib/flagsmith/sdk/models/flag.rb +62 -0
  63. data/lib/flagsmith/sdk/models/flags/collection.rb +105 -0
  64. data/lib/flagsmith/sdk/pooling_manager.rb +31 -0
  65. data/lib/flagsmith/version.rb +5 -0
  66. data/lib/flagsmith.rb +79 -101
  67. metadata +104 -6
  68. data/.gitignore +0 -57
  69. data/flagsmith.gemspec +0 -22
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Flagsmith
6
+ # Ruby client for flagsmith.com
7
+ class ApiClient
8
+ extend Forwardable
9
+
10
+ HTTP_METHODS_ALLOW_LIST = %i[get post].freeze
11
+
12
+ delegate HTTP_METHODS_ALLOW_LIST => :@conn
13
+
14
+ def initialize(config)
15
+ @conn = Faraday.new(url: config.api_url) do |f|
16
+ build_headers(f, config)
17
+ f.response :json, parser_options: { symbolize_names: true }
18
+ f.adapter Faraday.default_adapter
19
+
20
+ f.options.timeout = config.request_timeout_seconds
21
+ configure_logger(f, config)
22
+ configure_retries(f, config)
23
+ end
24
+
25
+ freeze
26
+ end
27
+
28
+ private
29
+
30
+ def build_headers(faraday, config)
31
+ faraday.headers['Accept'] = 'application/json'
32
+ faraday.headers['Content-Type'] = 'application/json'
33
+ faraday.headers['X-Environment-Key'] = config.environment_key
34
+ faraday.headers.merge(config.custom_headers)
35
+ end
36
+
37
+ def configure_logger(faraday, config)
38
+ faraday.response :logger, config.logger
39
+ end
40
+
41
+ def configure_retries(faraday, config)
42
+ return unless config.retries
43
+
44
+ faraday.request :retry, { max: config.retries }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ # Config options shared around Engine
5
+ class Config
6
+ DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1/'
7
+ OPTIONS = %i[
8
+ environment_key api_url custom_headers request_timeout_seconds enable_local_evaluation
9
+ environment_refresh_interval_seconds retries enable_analytics default_flag_handler logger
10
+ ].freeze
11
+
12
+ # Available Configs
13
+ #
14
+ # == Options:
15
+ #
16
+ # +environment_key+ - The environment key obtained from Flagsmith
17
+ # interface
18
+ # +api_url+ - Override the URL of the Flagsmith API to communicate with
19
+ # +customer_headers+ - Additional headers to add to requests made
20
+ # to the Flagsmith API
21
+ # +request_timeout_seconds+ - Number of seconds to wait for a request to
22
+ # complete before terminating the request
23
+ # Defaults to 10 seconds
24
+ # +enable_local_evaluation+ - Enables local evaluation of flags
25
+ # +environment_refresh_interval_seconds+ - If using local evaluation,
26
+ # specify the interval period between
27
+ # refreshes of local environment data
28
+ # +retries+ - a faraday retry option to use
29
+ # on all http requests to the Flagsmith API
30
+ # +enable_analytics+ - if enabled, sends additional requests to the Flagsmith
31
+ # API to power flag analytics charts
32
+ # +default_flag_handler+ - ruby block which will be used in the case where
33
+ # flags cannot be retrieved from the API or
34
+ # a non existent feature is requested.
35
+ # The searched feature#name will be passed to the block as an argument.
36
+ # +logger+ - Pass your logger, default is Logger.new($stdout)
37
+ #
38
+ attr_reader(*OPTIONS)
39
+
40
+ def initialize(options)
41
+ build_config(options)
42
+
43
+ freeze
44
+ end
45
+
46
+ def local_evaluation?
47
+ @enable_local_evaluation
48
+ end
49
+
50
+ def enable_analytics?
51
+ @enable_analytics
52
+ end
53
+
54
+ def environment_flags_url
55
+ 'flags/'
56
+ end
57
+
58
+ def identities_url
59
+ 'identities/'
60
+ end
61
+
62
+ def environment_url
63
+ 'environment-document/'
64
+ end
65
+
66
+ private
67
+
68
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
69
+ def build_config(options)
70
+ opts = options.is_a?(String) ? { environment_key: options } : options
71
+
72
+ @environment_key = opts.fetch(:environment_key, Flagsmith::Config.environment_key)
73
+ @api_url = opts.fetch(:api_url, Flagsmith::Config::DEFAULT_API_URL)
74
+ @custom_headers = opts.fetch(:custom_headers, {})
75
+ @request_timeout_seconds = opts.fetch(:request_timeout_seconds, 10)
76
+ @retries = opts[:retries]
77
+ @enable_local_evaluation = opts.fetch(:enable_local_evaluation, false)
78
+ @environment_refresh_interval_seconds = opts.fetch(:environment_refresh_interval_seconds, 60)
79
+ @enable_analytics = opts.fetch(:enable_analytics, false)
80
+ @default_flag_handler = opts[:default_flag_handler]
81
+ @logger = options.fetch(:logger, Logger.new($stdout).tap { |l| l.level = :debug })
82
+ end
83
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
84
+
85
+ class << self
86
+ def environment_key
87
+ ENV['FLAGSMITH_ENVIRONMENT_KEY']
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ class ClientError < StandardError; end
5
+
6
+ class APIError < StandardError; end
7
+
8
+ class FeatureStateNotFound < StandardError; end
9
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ module SDK
5
+ # Available Flagsmith Functions
6
+ module InstanceMethods
7
+ # Get all the default for flags for the current environment.
8
+ # @returns Flags object holding all the flags for the current environment.
9
+ def get_environment_flags # rubocop:disable Naming/AccessorMethodName
10
+ return environment_flags_from_document if @config.local_evaluation?
11
+
12
+ environment_flags_from_api
13
+ end
14
+
15
+ # Get all the flags for the current environment for a given identity. Will also
16
+ # upsert all traits to the Flagsmith API for future evaluations. Providing a
17
+ # trait with a value of None will remove the trait from the identity if it exists.
18
+ #
19
+ # identifier a unique identifier for the identity in the current
20
+ # environment, e.g. email address, username, uuid
21
+ # traits { key => value } is a dictionary of traits to add / update on the identity in
22
+ # Flagsmith, e.g. { "num_orders": 10 }
23
+ # returns Flags object holding all the flags for the given identity.
24
+ def get_identity_flags(identifier, **traits)
25
+ return get_identity_flags_from_document(identifier, traits) if environment
26
+
27
+ get_identity_flags_from_api(identifier, traits)
28
+ end
29
+
30
+ def feature_enabled?(feature_name, default: false)
31
+ flag = get_environment_flags[feature_name]
32
+ return default if flag.nil?
33
+
34
+ flag.enabled?
35
+ end
36
+
37
+ def feature_enabled_for_identity?(feature_name, user_id, default: false)
38
+ flag = get_identity_flags(user_id)[feature_name]
39
+ return default if flag.nil?
40
+
41
+ flag.enabled?
42
+ end
43
+
44
+ def get_value(feature_name, default: nil)
45
+ flag = get_environment_flags[feature_name]
46
+ return default if flag.nil?
47
+
48
+ flag.value
49
+ end
50
+
51
+ def get_value_for_identity(feature_name, user_id = nil, default: nil)
52
+ flag = get_identity_flags(user_id)[feature_name]
53
+ return default if flag.nil?
54
+
55
+ flag.value
56
+ end
57
+
58
+ private
59
+
60
+ def environment_flags_from_document
61
+ Flagsmith::Flags::Collection.from_feature_state_models(
62
+ get_environment_feature_states(environment),
63
+ analytics_processor: analytics_processor,
64
+ default_flag_handler: default_flag_handler
65
+ )
66
+ end
67
+
68
+ def get_identity_flags_from_document(identifier, traits = {})
69
+ identity_model = build_identity_model(identifier, traits)
70
+
71
+ Flagsmith::Flags::Collection.from_feature_state_models(
72
+ get_identity_feature_states(environment, identity_model),
73
+ analytics_processor: analytics_processor,
74
+ default_flag_handler: default_flag_handler
75
+ )
76
+ end
77
+
78
+ def environment_flags_from_api
79
+ rescue_with_default_handler do
80
+ api_flags = api_client.get(@config.environment_flags_url).body
81
+ api_flags = api_flags.select { |flag| flag[:feature_segment].nil? }
82
+ Flagsmith::Flags::Collection.from_api(
83
+ api_flags,
84
+ analytics_processor: analytics_processor,
85
+ default_flag_handler: default_flag_handler
86
+ )
87
+ end
88
+ end
89
+
90
+ def get_identity_flags_from_api(identifier, traits = {})
91
+ rescue_with_default_handler do
92
+ data = generate_identities_data(identifier, traits)
93
+ json_response = api_client.post(@config.identities_url, data.to_json).body
94
+
95
+ Flagsmith::Flags::Collection.from_api(
96
+ json_response[:flags],
97
+ analytics_processor: analytics_processor,
98
+ default_flag_handler: default_flag_handler
99
+ )
100
+ end
101
+ end
102
+
103
+ def rescue_with_default_handler
104
+ yield
105
+ rescue StandardError
106
+ if default_flag_handler
107
+ return Flagsmith::Flags::Collection.new(
108
+ {},
109
+ default_flag_handler: default_flag_handler
110
+ )
111
+ end
112
+ raise
113
+ end
114
+
115
+ def build_identity_model(identifier, traits = {})
116
+ unless environment
117
+ raise Flagsmith::ClientError,
118
+ 'Unable to build identity model when no local environment present.'
119
+ end
120
+
121
+ trait_models = traits.map do |key, value|
122
+ Flagsmith::Engine::Identities::Trait.new(trait_key: key, trait_value: value)
123
+ end
124
+ Flagsmith::Engine::Identity.new(
125
+ identity_traits: trait_models, environment_api_key: environment_key, identifier: identifier
126
+ )
127
+ end
128
+
129
+ def generate_identities_data(identifier, traits = {})
130
+ {
131
+ identifier: identifier,
132
+ traits: traits.map { |key, value| { trait_key: key, trait_value: value } }
133
+ }
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ module SDK
5
+ # Util functions
6
+ module Intervals
7
+ # @return [Thread] return loop thread reference
8
+ # rubocop:disable Naming/AccessorMethodName
9
+ def set_interval(delay)
10
+ Thread.new do
11
+ loop do
12
+ sleep delay
13
+ yield if block_given?
14
+ end
15
+ end
16
+ end
17
+ # rubocop:enable Naming/AccessorMethodName
18
+
19
+ def clear_interval(thread)
20
+ thread.kill
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ # Flag object
5
+ class Flag
6
+ include Comparable
7
+
8
+ attr_reader :enabled, :value, :default, :feature_name, :feature_id
9
+
10
+ def initialize(feature_name:, enabled:, value:, feature_id:, default: false)
11
+ @feature_name = feature_name
12
+ @feature_id = feature_id
13
+ @enabled = enabled
14
+ @value = value
15
+ @default = default
16
+ end
17
+
18
+ def enabled?
19
+ @enabled
20
+ end
21
+
22
+ alias is_default default
23
+
24
+ def <=>(other)
25
+ feature_name <=> other.feature_name
26
+ end
27
+
28
+ def [](key)
29
+ to_h[key]
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ feature_id: feature_id,
35
+ feature_name: feature_name,
36
+ value: value,
37
+ enabled: enabled,
38
+ default: default
39
+ }
40
+ end
41
+
42
+ class << self
43
+ def from_feature_state_model(feature_state_model, identity_id)
44
+ new(
45
+ enabled: feature_state_model.enabled,
46
+ value: feature_state_model.get_value(identity_id),
47
+ feature_name: feature_state_model.feature.name,
48
+ feature_id: feature_state_model.feature.id
49
+ )
50
+ end
51
+
52
+ def from_api(json_flag_data)
53
+ new(
54
+ enabled: json_flag_data[:enabled],
55
+ value: json_flag_data[:feature_state_value] || json_flag_data[:value],
56
+ feature_name: json_flag_data.dig(:feature, :name),
57
+ feature_id: json_flag_data.dig(:feature, :id)
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ module Flags
5
+ class NotFound < StandardError; end
6
+
7
+ # Flag Collection
8
+ class Collection
9
+ include Enumerable
10
+
11
+ attr_reader :flags, :default_flag_handler, :analytics_processor
12
+
13
+ def initialize(flags = {}, analytics_processor: nil, default_flag_handler: nil)
14
+ @flags = flags
15
+ @default_flag_handler = default_flag_handler
16
+ @analytics_processor = analytics_processor
17
+ end
18
+
19
+ def each(&block)
20
+ flags.each { |item| block&.call(item) || item }
21
+ end
22
+
23
+ def to_a
24
+ @flags.values || []
25
+ end
26
+ alias all_flags to_a
27
+
28
+ # Check whether a given feature is enabled.
29
+ # :param feature_name: the name of the feature to check if enabled.
30
+ # :return: Boolean representing the enabled state of a given feature.
31
+ # :raises FlagsmithClientError: if feature doesn't exist
32
+ def feature_enabled?(feature_name)
33
+ get_flag(feature_name).enabled?
34
+ end
35
+ alias is_feature_enabled feature_enabled?
36
+
37
+ # Get the value of a particular feature.
38
+ # :param feature_name: the name of the feature to retrieve the value of.
39
+ # :return: the value of the given feature.
40
+ # :raises FlagsmithClientError: if feature doesn't exist
41
+ def feature_value(feature_name)
42
+ get_flag(feature_name).value
43
+ end
44
+ alias get_feature_value feature_value
45
+
46
+ # Get a specific flag given the feature name.
47
+ # :param feature_name: the name of the feature to retrieve the flag for.
48
+ # :return: BaseFlag object.
49
+ # :raises FlagsmithClientError: if feature doesn't exist
50
+ def get_flag(feature_name)
51
+ key = Flagsmith::Flags::Collection.normalize_key(feature_name)
52
+ flag = flags.fetch(key)
53
+ @analytics_processor.track_feature(flag.feature_id) if @analytics_processor && flag.feature_id
54
+ flag
55
+ rescue KeyError
56
+ return @default_flag_handler.call(feature_name) if @default_flag_handler
57
+
58
+ raise Flagsmith::Flags::NotFound,
59
+ "Feature does not exist: #{key}, implement default_flag_handler to handle this case."
60
+ end
61
+
62
+ def [](key)
63
+ key.is_a?(Integer) ? to_a[key] : get_flag(key)
64
+ end
65
+
66
+ def length
67
+ to_a.length
68
+ end
69
+
70
+ def inspect
71
+ "<##{self.class}:#{object_id.to_s(8)} flags=#{@flags}>"
72
+ end
73
+
74
+ class << self
75
+ def from_api(json_data, **args)
76
+ to_flag_object = lambda { |json_flag, acc|
77
+ acc[normalize_key(json_flag.dig(:feature, :name))] =
78
+ Flagsmith::Flag.from_api(json_flag)
79
+ }
80
+
81
+ new(
82
+ json_data.each_with_object({}, &to_flag_object),
83
+ **args
84
+ )
85
+ end
86
+
87
+ def from_feature_state_models(feature_states, identity_id: nil, **args)
88
+ to_flag_object = lambda { |feature_state, acc|
89
+ acc[normalize_key(feature_state.feature.name)] =
90
+ Flagsmith::Flag.from_feature_state_model(feature_state, identity_id)
91
+ }
92
+
93
+ new(
94
+ feature_states.each_with_object({}, &to_flag_object),
95
+ **args
96
+ )
97
+ end
98
+
99
+ def normalize_key(key)
100
+ key.to_s.downcase
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'intervals'
4
+
5
+ module Flagsmith
6
+ # Manager to asynchronously fetch the environment
7
+ class EnvironmentDataPollingManager
8
+ include Flagsmith::SDK::Intervals
9
+
10
+ def initialize(main, refresh_interval_seconds)
11
+ @main = main
12
+ @refresh_interval_seconds = refresh_interval_seconds
13
+ end
14
+
15
+ def start
16
+ update_environment = lambda {
17
+ stop
18
+ @interval = set_interval(@refresh_interval_seconds) { @main.update_environment }
19
+ }
20
+
21
+ # TODO: this call should be awaited for getIdentityFlags/getEnvironmentFlags when enableLocalEvaluation is true
22
+ update_environment.call
23
+ end
24
+
25
+ def stop
26
+ return unless @interval
27
+
28
+ clear_interval(@interval)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagsmith
4
+ VERSION = '3.0.0'
5
+ end