diff --git a/Gemfile.lock b/Gemfile.lock index e6a41a5..0551d3c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -94,6 +98,7 @@ DEPENDENCIES flagsmith! gem-release pry + pry-byebug rake rspec rubocop diff --git a/flagsmith.gemspec b/flagsmith.gemspec index 9f1c979..5e10da5 100644 --- a/flagsmith.gemspec +++ b/flagsmith.gemspec @@ -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' diff --git a/lib/flagsmith.rb b/lib/flagsmith.rb index 56f00e9..8969930 100644 --- a/lib/flagsmith.rb +++ b/lib/flagsmith.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'pry' +require 'pry-byebug' + require 'faraday' require 'faraday/retry' require 'faraday_middleware' @@ -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' @@ -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 @@ -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? && !@config.offline_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 @@ -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? @@ -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 @@ -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) @@ -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 @@ -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 ) 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 = {}) diff --git a/lib/flagsmith/sdk/config.rb b/lib/flagsmith/sdk/config.rb index 6064de0..34ed31a 100644 --- a/lib/flagsmith/sdk/config.rb +++ b/lib/flagsmith/sdk/config.rb @@ -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 @@ -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) @@ -51,6 +56,10 @@ def enable_analytics? @enable_analytics end + def offline_mode? + @offline_mode + end + def environment_flags_url 'flags/' end @@ -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 diff --git a/lib/flagsmith/sdk/models/flags.rb b/lib/flagsmith/sdk/models/flags.rb index f62f0b9..3295822 100644 --- a/lib/flagsmith/sdk/models/flags.rb +++ b/lib/flagsmith/sdk/models/flags.rb @@ -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) @@ -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 + return @default_flag_handler.call(feature_name) if @default_flag_handler raise Flagsmith::Flags::NotFound, diff --git a/lib/flagsmith/sdk/offline_handlers.rb b/lib/flagsmith/sdk/offline_handlers.rb new file mode 100644 index 0000000..a53a6c6 --- /dev/null +++ b/lib/flagsmith/sdk/offline_handlers.rb @@ -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 diff --git a/spec/sdk/offline_mode_spec.rb b/spec/sdk/offline_mode_spec.rb new file mode 100644 index 0000000..ca8d4fc --- /dev/null +++ b/spec/sdk/offline_mode_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + + +RSpec.describe Flagsmith::Client do + it "gets environment flags with an environment document with an offline_handler" do + offline_handler = \ + Flagsmith::OfflineHandlers::LocalFileHandler.new("spec/sdk/fixtures/environment.json") + + flagsmith = Flagsmith::Client.new( + offline_mode: true, + offline_handler: offline_handler, + ) + + response = flagsmith.get_environment_flags + expect(response.count).to eq(1) + expect(response.first[-1].feature_name).to eq("some_feature") + end + + it "gets identity flags with an offline_handler" do + offline_handler = \ + Flagsmith::OfflineHandlers::LocalFileHandler.new("spec/sdk/fixtures/environment.json") + + flagsmith = Flagsmith::Client.new( + offline_mode: true, + offline_handler: offline_handler, + ) + + response = flagsmith.get_identity_flags("some_identity") + expect(response.first[-1].feature_name).to eq("some_feature") + end + + it "raises an error if offline_mode is present but offline_handler is missing" do + expect { + flagsmith = Flagsmith::Client.new(offline_mode: true) + }.to raise_error( + Flagsmith::ClientError, + "The offline_mode config param requires a matching offline_handler." + ) + end + + it "raises an error if both the default_flag_handler and offline_handler are used" do + default_flag_handler = lambda { |feature_name| + Flagsmith::Flags::DefaultFlag.new(enabled: false, value: {}.to_json) + } + offline_handler = \ + Flagsmith::OfflineHandlers::LocalFileHandler.new("spec/sdk/fixtures/environment.json") + expect { + Flagsmith::Client.new( + default_flag_handler: default_flag_handler, + offline_handler: offline_handler, + ) + }.to raise_error( + Flagsmith::ClientError, + "Cannot use offline_handler and default_flag_handler at the same time." + ) + end +end + +RSpec.describe Flagsmith::Flags::Collection do + it "works with get_flag with an offline_handler" do + offline_handler = \ + Flagsmith::OfflineHandlers::LocalFileHandler.new("spec/sdk/fixtures/environment.json") + + flags_collection = Flagsmith::Flags::Collection.new(offline_handler: offline_handler) + flag = flags_collection.get_flag("some_feature") + + expect(flag.feature_name).to eq("some_feature") + expect(flag.value).to eq("some-value") + end +end