flagsmith 2.0.0 → 3.0.0

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