Skip to content

Commit ee79786

Browse files
authored
Merge pull request #38 from Flagsmith/feat/add_offline_mode_for_sdk
feat: Add offline mode for using the client locally
2 parents 2ee853d + 60753eb commit ee79786

File tree

7 files changed

+214
-36
lines changed

7 files changed

+214
-36
lines changed

Gemfile.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ GEM
1111
remote: https://rubygems.org/
1212
specs:
1313
ast (2.4.2)
14+
byebug (11.1.3)
1415
coderay (1.1.3)
1516
diff-lcs (1.4.4)
1617
faraday (1.10.3)
@@ -50,6 +51,9 @@ GEM
5051
pry (0.14.1)
5152
coderay (~> 1.1)
5253
method_source (~> 1.0)
54+
pry-byebug (3.10.1)
55+
byebug (~> 11.0)
56+
pry (>= 0.13, < 0.15)
5357
racc (1.7.1)
5458
rainbow (3.1.1)
5559
rake (13.0.3)
@@ -94,6 +98,7 @@ DEPENDENCIES
9498
flagsmith!
9599
gem-release
96100
pry
101+
pry-byebug
97102
rake
98103
rspec
99104
rubocop

flagsmith.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
2727
spec.add_development_dependency 'bundler'
2828
spec.add_development_dependency 'gem-release'
2929
spec.add_development_dependency 'pry'
30+
spec.add_development_dependency 'pry-byebug'
3031
spec.add_development_dependency 'rake'
3132
spec.add_development_dependency 'rspec'
3233
spec.add_development_dependency 'rubocop'

lib/flagsmith.rb

Lines changed: 92 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# frozen_string_literal: true
22

3+
require 'pry'
4+
require 'pry-byebug'
5+
36
require 'faraday'
47
require 'faraday/retry'
58
require 'faraday_middleware'
@@ -16,6 +19,7 @@
1619
require 'flagsmith/sdk/pooling_manager'
1720
require 'flagsmith/sdk/models/flags'
1821
require 'flagsmith/sdk/models/segments'
22+
require 'flagsmith/sdk/offline_handlers'
1923

2024
require 'flagsmith/engine/core'
2125

@@ -44,7 +48,9 @@ class Client # rubocop:disable Metrics/ClassLength
4448
# Available Configs.
4549
#
4650
# :environment_key, :api_url, :custom_headers, :request_timeout_seconds, :enable_local_evaluation,
47-
# :environment_refresh_interval_seconds, :retries, :enable_analytics, :default_flag_handler
51+
# :environment_refresh_interval_seconds, :retries, :enable_analytics, :default_flag_handler,
52+
# :offline_mode, :offline_handler
53+
#
4854
# You can see full description in the Flagsmith::Config
4955

5056
attr_reader :config, :environment
@@ -55,10 +61,24 @@ def initialize(config)
5561
@_mutex = Mutex.new
5662
@config = Flagsmith::Config.new(config)
5763

64+
validate_offline_mode!
65+
5866
api_client
5967
analytics_processor
6068
environment_data_polling_manager
6169
engine
70+
load_offline_handler
71+
end
72+
73+
def validate_offline_mode!
74+
if @config.offline_mode? && !@config.offline_handler
75+
raise Flagsmith::ClientError,
76+
'The offline_mode config param requires a matching offline_handler.'
77+
end
78+
return unless @config.offline_handler && @config.default_flag_handler
79+
80+
raise Flagsmith::ClientError,
81+
'Cannot use offline_handler and default_flag_handler at the same time.'
6282
end
6383

6484
def api_client
@@ -79,6 +99,10 @@ def analytics_processor
7999
)
80100
end
81101

102+
def load_offline_handler
103+
@environment = offline_handler.environment if offline_handler
104+
end
105+
82106
def environment_data_polling_manager
83107
return nil unless @config.local_evaluation?
84108

@@ -103,7 +127,7 @@ def environment_from_api
103127
# Get all the default for flags for the current environment.
104128
# @returns Flags object holding all the flags for the current environment.
105129
def get_environment_flags # rubocop:disable Naming/AccessorMethodName
106-
return environment_flags_from_document if @config.local_evaluation?
130+
return environment_flags_from_document if @config.local_evaluation? || @config.offline_mode
107131

108132
environment_flags_from_api
109133
end
@@ -154,7 +178,7 @@ def get_value_for_identity(feature_name, user_id = nil, default: nil)
154178
def get_identity_segments(identifier, traits = {})
155179
unless environment
156180
raise Flagsmith::ClientError,
157-
'Local evaluation required to obtain identity segments.'
181+
'Local evaluation or offline handler is required to obtain identity segments.'
158182
end
159183

160184
identity_model = build_identity_model(identifier, traits)
@@ -168,7 +192,8 @@ def environment_flags_from_document
168192
Flagsmith::Flags::Collection.from_feature_state_models(
169193
engine.get_environment_feature_states(environment),
170194
analytics_processor: analytics_processor,
171-
default_flag_handler: default_flag_handler
195+
default_flag_handler: default_flag_handler,
196+
offline_handler: offline_handler
172197
)
173198
end
174199

@@ -178,45 +203,80 @@ def get_identity_flags_from_document(identifier, traits = {})
178203
Flagsmith::Flags::Collection.from_feature_state_models(
179204
engine.get_identity_feature_states(environment, identity_model),
180205
analytics_processor: analytics_processor,
181-
default_flag_handler: default_flag_handler
206+
default_flag_handler: default_flag_handler,
207+
offline_handler: offline_handler
182208
)
183209
end
184210

211+
# rubocop:disable Metrics/MethodLength
185212
def environment_flags_from_api
186-
rescue_with_default_handler do
187-
api_flags = api_client.get(@config.environment_flags_url).body
188-
api_flags = api_flags.select { |flag| flag[:feature_segment].nil? }
189-
Flagsmith::Flags::Collection.from_api(
190-
api_flags,
191-
analytics_processor: analytics_processor,
192-
default_flag_handler: default_flag_handler
193-
)
213+
if offline_handler
214+
begin
215+
process_environment_flags_from_api
216+
rescue StandardError
217+
environment_flags_from_document
218+
end
219+
else
220+
begin
221+
process_environment_flags_from_api
222+
rescue StandardError
223+
if default_flag_handler
224+
return Flagsmith::Flags::Collection.new(
225+
{},
226+
default_flag_handler: default_flag_handler
227+
)
228+
end
229+
raise
230+
end
194231
end
195232
end
233+
# rubocop:enable Metrics/MethodLength
196234

235+
def process_environment_flags_from_api
236+
api_flags = api_client.get(@config.environment_flags_url).body
237+
api_flags = api_flags.select { |flag| flag[:feature_segment].nil? }
238+
Flagsmith::Flags::Collection.from_api(
239+
api_flags,
240+
analytics_processor: analytics_processor,
241+
default_flag_handler: default_flag_handler,
242+
offline_handler: offline_handler
243+
)
244+
end
245+
246+
# rubocop:disable Metrics/MethodLength
197247
def get_identity_flags_from_api(identifier, traits = {})
198-
rescue_with_default_handler do
199-
data = generate_identities_data(identifier, traits)
200-
json_response = api_client.post(@config.identities_url, data.to_json).body
201-
202-
Flagsmith::Flags::Collection.from_api(
203-
json_response[:flags],
204-
analytics_processor: analytics_processor,
205-
default_flag_handler: default_flag_handler
206-
)
248+
if offline_handler
249+
begin
250+
process_identity_flags_from_api(identifier, traits)
251+
rescue StandardError
252+
get_identity_flags_from_document(identifier, traits)
253+
end
254+
else
255+
begin
256+
process_identity_flags_from_api(identifier, traits)
257+
rescue StandardError
258+
if default_flag_handler
259+
return Flagsmith::Flags::Collection.new(
260+
{},
261+
default_flag_handler: default_flag_handler
262+
)
263+
end
264+
raise
265+
end
207266
end
208267
end
268+
# rubocop:enable Metrics/MethodLength
209269

210-
def rescue_with_default_handler
211-
yield
212-
rescue StandardError
213-
if default_flag_handler
214-
return Flagsmith::Flags::Collection.new(
215-
{},
216-
default_flag_handler: default_flag_handler
217-
)
218-
end
219-
raise
270+
def process_identity_flags_from_api(identifier, traits = {})
271+
data = generate_identities_data(identifier, traits)
272+
json_response = api_client.post(@config.identities_url, data.to_json).body
273+
274+
Flagsmith::Flags::Collection.from_api(
275+
json_response[:flags],
276+
analytics_processor: analytics_processor,
277+
default_flag_handler: default_flag_handler,
278+
offline_handler: offline_handler
279+
)
220280
end
221281

222282
def build_identity_model(identifier, traits = {})

lib/flagsmith/sdk/config.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ class Config
66
DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1/'
77
OPTIONS = %i[
88
environment_key api_url custom_headers request_timeout_seconds enable_local_evaluation
9-
environment_refresh_interval_seconds retries enable_analytics default_flag_handler logger
9+
environment_refresh_interval_seconds retries enable_analytics default_flag_handler
10+
offline_mode offline_handler logger
1011
].freeze
1112

1213
# Available Configs
@@ -31,8 +32,12 @@ class Config
3132
# API to power flag analytics charts
3233
# +default_flag_handler+ - ruby block which will be used in the case where
3334
# flags cannot be retrieved from the API or
34-
# a non existent feature is requested.
35+
# a non-existent feature is requested.
3536
# The searched feature#name will be passed to the block as an argument.
37+
# +offline_mode+ - if enabled, uses a locally provided file and
38+
# bypasses requests to the api.
39+
# +offline_handler+ - A file object that contains a JSON serialization of
40+
# the entire environment, project, flags, etc.
3641
# +logger+ - Pass your logger, default is Logger.new($stdout)
3742
#
3843
attr_reader(*OPTIONS)
@@ -51,6 +56,10 @@ def enable_analytics?
5156
@enable_analytics
5257
end
5358

59+
def offline_mode?
60+
@offline_mode
61+
end
62+
5463
def environment_flags_url
5564
'flags/'
5665
end
@@ -78,6 +87,8 @@ def build_config(options)
7887
@environment_refresh_interval_seconds = opts.fetch(:environment_refresh_interval_seconds, 60)
7988
@enable_analytics = opts.fetch(:enable_analytics, false)
8089
@default_flag_handler = opts[:default_flag_handler]
90+
@offline_mode = opts.fetch(:offline_mode, false)
91+
@offline_handler = opts[:offline_handler]
8192
@logger = options.fetch(:logger, Logger.new($stdout).tap { |l| l.level = :debug })
8293
end
8394
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength

lib/flagsmith/sdk/models/flags.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,13 @@ def from_api(json_flag_data)
8484
class Collection
8585
include Enumerable
8686

87-
attr_reader :flags, :default_flag_handler, :analytics_processor
87+
attr_reader :flags, :default_flag_handler, :analytics_processor, :offline_handler
8888

89-
def initialize(flags = {}, analytics_processor: nil, default_flag_handler: nil)
89+
def initialize(flags = {}, analytics_processor: nil, default_flag_handler: nil, offline_handler: nil)
9090
@flags = flags
9191
@default_flag_handler = default_flag_handler
9292
@analytics_processor = analytics_processor
93+
@offline_handler = offline_handler
9394
end
9495

9596
def each(&block)
@@ -119,16 +120,27 @@ def feature_value(feature_name)
119120
end
120121
alias get_feature_value feature_value
121122

123+
def get_flag_from_offline_handler(key)
124+
@offline_handler.environment.feature_states.each do |feature_state|
125+
return Flag.from_feature_state_model(feature_state, nil) if key == Flagsmith::Flags::Collection.normalize_key(feature_state.feature.name)
126+
end
127+
raise Flagsmith::Flags::NotFound,
128+
"Feature does not exist: #{key}, offline_handler did not find a flag in this case."
129+
end
130+
122131
# Get a specific flag given the feature name.
123132
# :param feature_name: the name of the feature to retrieve the flag for.
124133
# :return: BaseFlag object.
125134
# :raises FlagsmithClientError: if feature doesn't exist
126135
def get_flag(feature_name)
127136
key = Flagsmith::Flags::Collection.normalize_key(feature_name)
137+
128138
flag = flags.fetch(key)
129139
@analytics_processor.track_feature(flag.feature_name) if @analytics_processor && flag.feature_id
130140
flag
131141
rescue KeyError
142+
return get_flag_from_offline_handler(key) if @offline_handler
143+
132144
return @default_flag_handler.call(feature_name) if @default_flag_handler
133145

134146
raise Flagsmith::Flags::NotFound,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module Flagsmith
4+
module OfflineHandlers
5+
# Provides the offline_handler to the Flagsmith::Client.
6+
class LocalFileHandler
7+
attr_reader :environment
8+
9+
def initialize(environment_document_path)
10+
environment_file = File.open(environment_document_path)
11+
12+
data = JSON.parse(environment_file.read, symbolize_names: true)
13+
@environment = Flagsmith::Engine::Environment.build(data)
14+
end
15+
end
16+
end
17+
end

0 commit comments

Comments
 (0)