diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index 052d4ce0..28b8cc08 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -43,6 +43,7 @@ class Config # @option opts [BigSegmentsConfig] :big_segments See {#big_segments}. # @option opts [Hash] :application See {#application} # @option opts [String] :payload_filter_key See {#payload_filter_key} + # @option hooks [Array] + # @param evaluation_series_context [EvaluationSeriesContext] + # + # @return [Array] + # + private def execute_before_evaluation(hooks, evaluation_series_context) + hooks.map do |hook| + try_execute_stage(:before_evaluation, hook.metadata.name) do + hook.before_evaluation(evaluation_series_context, {}) + end + end + end + + # + # Execute the :after_evaluation stage of the evaluation series. + # + # This method will return the results of each hook, indexed into an array in the same order as the hooks. If a hook + # raised an uncaught exception, the value will be nil. + # + # @param hooks [Array] + # @param evaluation_series_context [EvaluationSeriesContext] + # @param hook_data [Array] + # @param evaluation_detail [EvaluationDetail] + # + # @return [Array] + # + private def execute_after_evaluation(hooks, evaluation_series_context, hook_data, evaluation_detail) + hooks.zip(hook_data).reverse.map do |(hook, data)| + try_execute_stage(:after_evaluation, hook.metadata.name) do + hook.after_evaluation(evaluation_series_context, data, evaluation_detail) + end + end + end + + # + # Try to execute the provided block. If execution raises an exception, catch and log it, then move on with + # execution. + # + # @return [any] + # + private def try_execute_stage(method, hook_name) + begin + yield + rescue => e + @config.logger.error { "[LDClient] An error occurred in #{method} of the hook #{hook_name}: #{e}" } + nil + end + end + + # + # Return a copy of the existing hooks and a few instance of the EvaluationSeriesContext used for the evaluation series. + # + # @param key [String] + # @param context [LDContext] + # @param default [any] + # @param method [Symbol] + # @return [Array[Array, Interfaces::Hooks::EvaluationSeriesContext]] + # + private def prepare_hooks(key, context, default, method) + # Copy the hooks to use a consistent set during the evaluation series. + # + # Hooks can be added and we want to ensure all correct stages for a given hook execute. For example, we do not + # want to trigger the after_evaluation method without also triggering the before_evaluation method. + hooks = @hooks.dup + evaluation_series_context = Interfaces::Hooks::EvaluationSeriesContext.new(key, context, default, method) + + [hooks, evaluation_series_context] + end + # # This method returns the migration stage of the migration feature flag for the given evaluation context. # @@ -508,7 +633,9 @@ def create_default_data_source(sdk_key, config, diagnostic_accumulator) # @return [Array] # def variation_with_flag(key, context, default) - evaluate_internal(key, context, default, false) + evaluate_with_hooks(key, context, default, :variation_detail) do + evaluate_internal(key, context, default, false) + end end # diff --git a/spec/ldclient_hooks_spec.rb b/spec/ldclient_hooks_spec.rb new file mode 100644 index 00000000..887bd829 --- /dev/null +++ b/spec/ldclient_hooks_spec.rb @@ -0,0 +1,152 @@ +require "ldclient-rb" + +require "mock_components" +require "model_builders" +require "spec_helper" + +module LaunchDarkly + describe "LDClient hooks tests" do + context "registration" do + it "can register a hook on the config" do + count = 0 + hook = MockHook.new(->(_, _) { count += 1 }, ->(_, _, _) { count += 2 }) + with_client(test_config(hooks: [hook])) do |client| + client.variation("doesntmatter", basic_context, "default") + expect(count).to eq 3 + end + end + + it "can register a hook on the client" do + count = 0 + hook = MockHook.new(->(_, _) { count += 1 }, ->(_, _, _) { count += 2 }) + with_client(test_config()) do |client| + client.add_hook(hook) + client.variation("doesntmatter", basic_context, "default") + + expect(count).to eq 3 + end + end + + it "can register hooks on both" do + count = 0 + config_hook = MockHook.new(->(_, _) { count += 1 }, ->(_, _, _) { count += 2 }) + client_hook = MockHook.new(->(_, _) { count += 4 }, ->(_, _, _) { count += 8 }) + + with_client(test_config(hooks: [config_hook])) do |client| + client.add_hook(client_hook) + client.variation("doesntmatter", basic_context, "default") + + expect(count).to eq 15 + end + end + + it "will drop invalid hooks on config" do + config = test_config(hooks: [true, nil, "example thing"]) + expect(config.hooks.count).to eq 0 + end + + it "will drop invalid hooks on client" do + with_client(test_config) do |client| + client.add_hook(true) + client.add_hook(nil) + client.add_hook("example thing") + + expect(client.instance_variable_get("@hooks").count).to eq 0 + end + + config = test_config(hooks: [true, nil, "example thing"]) + expect(config.hooks.count).to eq 0 + end + end + + context "execution order" do + it "config order is preserved" do + order = [] + first_hook = MockHook.new(->(_, _) { order << "first before" }, ->(_, _, _) { order << "first after" }) + second_hook = MockHook.new(->(_, _) { order << "second before" }, ->(_, _, _) { order << "second after" }) + + with_client(test_config(hooks: [first_hook, second_hook])) do |client| + client.variation("doesntmatter", basic_context, "default") + expect(order).to eq ["first before", "second before", "second after", "first after"] + end + end + + it "client order is preserved" do + order = [] + first_hook = MockHook.new(->(_, _) { order << "first before" }, ->(_, _, _) { order << "first after" }) + second_hook = MockHook.new(->(_, _) { order << "second before" }, ->(_, _, _) { order << "second after" }) + + with_client(test_config()) do |client| + client.add_hook(first_hook) + client.add_hook(second_hook) + client.variation("doesntmatter", basic_context, "default") + + expect(order).to eq ["first before", "second before", "second after", "first after"] + end + end + + it "config hooks precede client hooks" do + order = [] + config_hook = MockHook.new(->(_, _) { order << "config before" }, ->(_, _, _) { order << "config after" }) + client_hook = MockHook.new(->(_, _) { order << "client before" }, ->(_, _, _) { order << "client after" }) + + with_client(test_config(hooks: [config_hook])) do |client| + client.add_hook(client_hook) + client.variation("doesntmatter", basic_context, "default") + + expect(order).to eq ["config before", "client before", "client after", "config after"] + end + end + end + + context "passing data" do + it "hook receives EvaluationDetail" do + td = Integrations::TestData.data_source + td.update(td.flag("flagkey").variations("value").variation_for_all(0)) + + detail = nil + config_hook = MockHook.new(->(_, _) { }, ->(_, _, d) { detail = d }) + with_client(test_config(data_source: td, hooks: [config_hook])) do |client| + client.variation("flagkey", basic_context, "default") + + expect(detail.value).to eq "value" + expect(detail.variation_index).to eq 0 + expect(detail.reason).to eq EvaluationReason::fallthrough + end + end + + it "from before evaluation to after evaluation" do + actual = nil + config_hook = MockHook.new(->(_, _) { "example string returned" }, ->(_, hook_data, _) { actual = hook_data }) + with_client(test_config(hooks: [config_hook])) do |client| + client.variation("doesntmatter", basic_context, "default") + + expect(actual).to eq "example string returned" + end + end + + it "exception receives nil value" do + actual = nil + config_hook = MockHook.new(->(_, _) { raise "example string returned" }, ->(_, hook_data, _) { actual = hook_data }) + with_client(test_config(hooks: [config_hook])) do |client| + client.variation("doesntmatter", basic_context, "default") + + expect(actual).to be_nil + end + end + + it "exceptions do not mess up data passing order" do + data = [] + first_hook = MockHook.new(->(_, _) { "first hook" }, ->(_, hook_data, _) { data << hook_data }) + second_hook = MockHook.new(->(_, _) { raise "second hook" }, ->(_, hook_data, _) { data << hook_data }) + third_hook = MockHook.new(->(_, _) { "third hook" }, ->(_, hook_data, _) { data << hook_data }) + with_client(test_config(hooks: [first_hook, second_hook, third_hook])) do |client| + client.variation("doesntmatter", basic_context, "default") + + # NOTE: These are reversed since the push happens in the after_evaluation (when hooks are reversed) + expect(data).to eq ["third hook", nil, "first hook"] + end + end + end + end +end diff --git a/spec/mock_components.rb b/spec/mock_components.rb index 3866b9f3..4eb0e7a8 100644 --- a/spec/mock_components.rb +++ b/spec/mock_components.rb @@ -97,4 +97,25 @@ def self.adding_to_queue(q) new(->(value) { q << value }) end end + + class MockHook + include Interfaces::Hooks::Hook + + def initialize(before_evaluation, after_evaluation) + @before_evaluation = before_evaluation + @after_evaluation = after_evaluation + end + + def metadata + Interfaces::Hooks::Metadata.new("mock hook") + end + + def before_evaluation(evaluation_series_context, data) + @before_evaluation.call(evaluation_series_context, data) + end + + def after_evaluation(evaluation_series_context, data, detail) + @after_evaluation.call(evaluation_series_context, data, detail) + end + end end