Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/open_feature/sdk/hooks/hook_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ def initialize(flag_key:, flag_value_type:, default_value:, evaluation_context:,
@evaluation_context = evaluation_context
@client_metadata = client_metadata
@provider_metadata = provider_metadata
@hook_data = {}
end

# Returns a mutable hash scoped to the given hook instance.
# The same hash is returned across all hook stages (before, after, error, finally),
# allowing hooks to share state across their lifecycle (spec 4.1.5, 4.6.1).
def hook_data_for(hook)
@hook_data[hook.object_id] ||= {}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using hook.object_id as a key in the hash can be problematic. While unlikely in short-lived processes, object_ids can be reused by the Ruby garbage collector for new objects after old ones are destroyed. This could lead to data leakage between different hook instances over the lifetime of a long-running application. A more robust approach is to use the hook object itself as the key. Ruby's Hash handles object keys correctly based on their identity, which is safer and more idiomatic.

          @hook_data[hook] ||= {}

end
end
end
Expand Down
111 changes: 111 additions & 0 deletions spec/specification/hooks_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,72 @@
end
end

context "Requirement 4.1.5" do
specify "Hook context MUST provide a mechanism for hook instances to store and retrieve per-hook data" do
data_across_stages = {}

hook = Class.new do
include OpenFeature::SDK::Hooks::Hook

define_method(:before) do |hook_context:, hints:|
hook_context.hook_data_for(self)["my_key"] = "set_in_before"
nil
end

define_method(:after) do |hook_context:, evaluation_details:, hints:|
data_across_stages[:after_value] = hook_context.hook_data_for(self)["my_key"]
end

define_method(:finally) do |hook_context:, evaluation_details:, hints:|
data_across_stages[:finally_value] = hook_context.hook_data_for(self)["my_key"]
end
end.new

client = OpenFeature::SDK.build_client
client.fetch_boolean_value(flag_key: "flag-1", default_value: false, hooks: [hook])

expect(data_across_stages[:after_value]).to eq("set_in_before")
expect(data_across_stages[:finally_value]).to eq("set_in_before")
end

specify "Hook data MUST be isolated between different hook instances" do
hook_a_data = {}
hook_b_data = {}

hook_a = Class.new do
include OpenFeature::SDK::Hooks::Hook

define_method(:before) do |hook_context:, hints:|
hook_context.hook_data_for(self)["owner"] = "hook_a"
nil
end

define_method(:after) do |hook_context:, evaluation_details:, hints:|
hook_a_data[:owner] = hook_context.hook_data_for(self)["owner"]
end
end.new

hook_b = Class.new do
include OpenFeature::SDK::Hooks::Hook

define_method(:before) do |hook_context:, hints:|
hook_context.hook_data_for(self)["owner"] = "hook_b"
nil
end

define_method(:after) do |hook_context:, evaluation_details:, hints:|
hook_b_data[:owner] = hook_context.hook_data_for(self)["owner"]
end
end.new

client = OpenFeature::SDK.build_client
client.fetch_boolean_value(flag_key: "flag-1", default_value: false, hooks: [hook_a, hook_b])

expect(hook_a_data[:owner]).to eq("hook_a")
expect(hook_b_data[:owner]).to eq("hook_b")
end
end

context "Requirement 4.1.4" do
specify "evaluation context MUST be mutable" do
captured_context = nil
Expand Down Expand Up @@ -246,6 +312,51 @@ def before(hook_context:, hints:)
expect(finally_count).to eq(2)
end
end

context "Requirement 4.3.8" do
specify "On success, finally hook receives evaluation details with the resolved value" do
captured_details = nil

hook = Class.new do
include OpenFeature::SDK::Hooks::Hook

define_method(:finally) do |hook_context:, evaluation_details:, hints:|
captured_details = evaluation_details
end
end.new

client = OpenFeature::SDK.build_client
client.fetch_boolean_value(flag_key: "flag-1", default_value: false, hooks: [hook])

expect(captured_details).not_to be_nil
expect(captured_details.value).to eq(true)
expect(captured_details.flag_key).to eq("flag-1")
end

specify "On error, finally hook receives evaluation details with default value and error info" do
captured_details = nil

hook = Class.new do
include OpenFeature::SDK::Hooks::Hook

define_method(:finally) do |hook_context:, evaluation_details:, hints:|
captured_details = evaluation_details
end
end.new

allow(provider).to receive(:fetch_boolean_value).and_raise("provider error")

client = OpenFeature::SDK.build_client
client.fetch_boolean_value(flag_key: "flag-1", default_value: false, hooks: [hook])

expect(captured_details).not_to be_nil
expect(captured_details.value).to eq(false)
expect(captured_details.flag_key).to eq("flag-1")
expect(captured_details.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL)
expect(captured_details.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR)
expect(captured_details.error_message).to eq("provider error")
end
end
end

context "4.4 - Hook Execution" do
Expand Down