Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add offline mode for using the client locally #38

Merged
merged 10 commits into from
Jan 2, 2024
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ GEM
remote: https://rubygems.org/
specs:
ast (2.4.2)
byebug (11.1.3)
coderay (1.1.3)
diff-lcs (1.4.4)
faraday (1.10.3)
Expand Down Expand Up @@ -50,6 +51,9 @@ GEM
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
pry-byebug (3.10.1)
byebug (~> 11.0)
pry (>= 0.13, < 0.15)
racc (1.7.1)
rainbow (3.1.1)
rake (13.0.3)
Expand Down Expand Up @@ -94,6 +98,7 @@ DEPENDENCIES
flagsmith!
gem-release
pry
pry-byebug
rake
rspec
rubocop
Expand Down
1 change: 1 addition & 0 deletions flagsmith.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
spec.add_development_dependency 'bundler'
spec.add_development_dependency 'gem-release'
spec.add_development_dependency 'pry'
spec.add_development_dependency 'pry-byebug'
spec.add_development_dependency 'rake'
spec.add_development_dependency 'rspec'
spec.add_development_dependency 'rubocop'
Expand Down
124 changes: 92 additions & 32 deletions lib/flagsmith.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# frozen_string_literal: true

require 'pry'
require 'pry-byebug'

require 'faraday'
require 'faraday/retry'
require 'faraday_middleware'
Expand All @@ -16,6 +19,7 @@
require 'flagsmith/sdk/pooling_manager'
require 'flagsmith/sdk/models/flags'
require 'flagsmith/sdk/models/segments'
require 'flagsmith/sdk/offline_handlers'

require 'flagsmith/engine/core'

Expand Down Expand Up @@ -44,7 +48,9 @@ class Client # rubocop:disable Metrics/ClassLength
# Available Configs.
#
# :environment_key, :api_url, :custom_headers, :request_timeout_seconds, :enable_local_evaluation,
# :environment_refresh_interval_seconds, :retries, :enable_analytics, :default_flag_handler
# :environment_refresh_interval_seconds, :retries, :enable_analytics, :default_flag_handler,
# :offline_mode, :offline_handler
#
# You can see full description in the Flagsmith::Config

attr_reader :config, :environment
Expand All @@ -55,10 +61,24 @@ def initialize(config)
@_mutex = Mutex.new
@config = Flagsmith::Config.new(config)

validate_offline_mode!

api_client
analytics_processor
environment_data_polling_manager
engine
load_offline_handler
end

def validate_offline_mode!
if @config.offline_mode? && [email protected]_handler
raise Flagsmith::ClientError,
'The offline_mode config param requires a matching offline_handler.'
end
return unless @config.offline_handler && @config.default_flag_handler

raise Flagsmith::ClientError,
'Cannot use offline_handler and default_flag_handler at the same time.'
end

def api_client
Expand All @@ -79,6 +99,10 @@ def analytics_processor
)
end

def load_offline_handler
@environment = offline_handler.environment if offline_handler
end

def environment_data_polling_manager
return nil unless @config.local_evaluation?

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

environment_flags_from_api
end
Expand Down Expand Up @@ -154,7 +178,7 @@ def get_value_for_identity(feature_name, user_id = nil, default: nil)
def get_identity_segments(identifier, traits = {})
unless environment
raise Flagsmith::ClientError,
'Local evaluation required to obtain identity segments.'
'Local evaluation or offline handler is required to obtain identity segments.'
end

identity_model = build_identity_model(identifier, traits)
Expand All @@ -168,7 +192,8 @@ def environment_flags_from_document
Flagsmith::Flags::Collection.from_feature_state_models(
engine.get_environment_feature_states(environment),
analytics_processor: analytics_processor,
default_flag_handler: default_flag_handler
default_flag_handler: default_flag_handler,
offline_handler: offline_handler
)
end

Expand All @@ -178,45 +203,80 @@ def get_identity_flags_from_document(identifier, traits = {})
Flagsmith::Flags::Collection.from_feature_state_models(
engine.get_identity_feature_states(environment, identity_model),
analytics_processor: analytics_processor,
default_flag_handler: default_flag_handler
default_flag_handler: default_flag_handler,
offline_handler: offline_handler
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved
)
end

# rubocop:disable Metrics/MethodLength
def environment_flags_from_api
rescue_with_default_handler do
api_flags = api_client.get(@config.environment_flags_url).body
api_flags = api_flags.select { |flag| flag[:feature_segment].nil? }
Flagsmith::Flags::Collection.from_api(
api_flags,
analytics_processor: analytics_processor,
default_flag_handler: default_flag_handler
)
if offline_handler
begin
process_environment_flags_from_api
rescue StandardError
environment_flags_from_document
end
else
begin
process_environment_flags_from_api
rescue StandardError
if default_flag_handler
return Flagsmith::Flags::Collection.new(
{},
default_flag_handler: default_flag_handler
)
end
raise
end
end
end
# rubocop:enable Metrics/MethodLength

def process_environment_flags_from_api
api_flags = api_client.get(@config.environment_flags_url).body
api_flags = api_flags.select { |flag| flag[:feature_segment].nil? }
Flagsmith::Flags::Collection.from_api(
api_flags,
analytics_processor: analytics_processor,
default_flag_handler: default_flag_handler,
offline_handler: offline_handler
)
end

# rubocop:disable Metrics/MethodLength
def get_identity_flags_from_api(identifier, traits = {})
rescue_with_default_handler do
data = generate_identities_data(identifier, traits)
json_response = api_client.post(@config.identities_url, data.to_json).body

Flagsmith::Flags::Collection.from_api(
json_response[:flags],
analytics_processor: analytics_processor,
default_flag_handler: default_flag_handler
)
if offline_handler
begin
process_identity_flags_from_api(identifier, traits)
rescue StandardError
get_identity_flags_from_document(identifier, traits)
end
else
begin
process_identity_flags_from_api(identifier, traits)
rescue StandardError
if default_flag_handler
return Flagsmith::Flags::Collection.new(
{},
default_flag_handler: default_flag_handler
)
end
raise
end
end
end
# rubocop:enable Metrics/MethodLength

def rescue_with_default_handler
yield
rescue StandardError
if default_flag_handler
return Flagsmith::Flags::Collection.new(
{},
default_flag_handler: default_flag_handler
)
end
raise
def process_identity_flags_from_api(identifier, traits = {})
data = generate_identities_data(identifier, traits)
json_response = api_client.post(@config.identities_url, data.to_json).body

Flagsmith::Flags::Collection.from_api(
json_response[:flags],
analytics_processor: analytics_processor,
default_flag_handler: default_flag_handler,
offline_handler: offline_handler
)
end

def build_identity_model(identifier, traits = {})
Expand Down
15 changes: 13 additions & 2 deletions lib/flagsmith/sdk/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ class Config
DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1/'
OPTIONS = %i[
environment_key api_url custom_headers request_timeout_seconds enable_local_evaluation
environment_refresh_interval_seconds retries enable_analytics default_flag_handler logger
environment_refresh_interval_seconds retries enable_analytics default_flag_handler
offline_mode offline_handler logger
].freeze

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

def offline_mode?
@offline_mode
end

def environment_flags_url
'flags/'
end
Expand Down Expand Up @@ -78,6 +87,8 @@ def build_config(options)
@environment_refresh_interval_seconds = opts.fetch(:environment_refresh_interval_seconds, 60)
@enable_analytics = opts.fetch(:enable_analytics, false)
@default_flag_handler = opts[:default_flag_handler]
@offline_mode = opts.fetch(:offline_mode, false)
@offline_handler = opts[:offline_handler]
@logger = options.fetch(:logger, Logger.new($stdout).tap { |l| l.level = :debug })
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
Expand Down
16 changes: 14 additions & 2 deletions lib/flagsmith/sdk/models/flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,13 @@ def from_api(json_flag_data)
class Collection
include Enumerable

attr_reader :flags, :default_flag_handler, :analytics_processor
attr_reader :flags, :default_flag_handler, :analytics_processor, :offline_handler

def initialize(flags = {}, analytics_processor: nil, default_flag_handler: nil)
def initialize(flags = {}, analytics_processor: nil, default_flag_handler: nil, offline_handler: nil)
@flags = flags
@default_flag_handler = default_flag_handler
@analytics_processor = analytics_processor
@offline_handler = offline_handler
end

def each(&block)
Expand Down Expand Up @@ -119,16 +120,27 @@ def feature_value(feature_name)
end
alias get_feature_value feature_value

def get_flag_from_offline_handler(key)
@offline_handler.environment.feature_states.each do |feature_state|
return Flag.from_feature_state_model(feature_state, nil) if key == Flagsmith::Flags::Collection.normalize_key(feature_state.feature.name)
end
raise Flagsmith::Flags::NotFound,
"Feature does not exist: #{key}, offline_handler did not find a flag in this case."
end

# Get a specific flag given the feature name.
# :param feature_name: the name of the feature to retrieve the flag for.
# :return: BaseFlag object.
# :raises FlagsmithClientError: if feature doesn't exist
def get_flag(feature_name)
key = Flagsmith::Flags::Collection.normalize_key(feature_name)

flag = flags.fetch(key)
@analytics_processor.track_feature(flag.feature_name) if @analytics_processor && flag.feature_id
flag
rescue KeyError
return get_flag_from_offline_handler(key) if @offline_handler
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved

return @default_flag_handler.call(feature_name) if @default_flag_handler

raise Flagsmith::Flags::NotFound,
Expand Down
17 changes: 17 additions & 0 deletions lib/flagsmith/sdk/offline_handlers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module Flagsmith
module OfflineHandlers
# Provides the offline_handler to the Flagsmith::Client.
class LocalFileHandler
attr_reader :environment

def initialize(environment_document_path)
environment_file = File.open(environment_document_path)

data = JSON.parse(environment_file.read, symbolize_names: true)
@environment = Flagsmith::Engine::Environment.build(data)
end
end
end
end
Loading