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
1 change: 1 addition & 0 deletions lib/open_feature/sdk/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require_relative "evaluation_context_builder"
require_relative "evaluation_details"
require_relative "client_metadata"
require_relative "hooks"
require_relative "client"
require_relative "provider"

Expand Down
58 changes: 49 additions & 9 deletions lib/open_feature/sdk/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class Client
}.freeze
RESULT_TYPE = TYPE_CLASS_MAP.keys.freeze
SUFFIXES = %i[value details].freeze
EMPTY_HINTS = Hooks::Hints.new.freeze

attr_reader :metadata, :evaluation_context

Expand All @@ -40,11 +41,8 @@ def remove_handler(event_type, handler = nil, &block)
RESULT_TYPE.each do |result_type|
SUFFIXES.each do |suffix|
class_eval <<-RUBY, __FILE__, __LINE__ + 1
# def fetch_boolean_details(flag_key:, default_value:, evaluation_context: nil)
# result = @provider.fetch_boolean_value(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context)
# end
def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context: nil)
evaluation_details = fetch_details(type: :#{result_type}, flag_key:, default_value:, evaluation_context:)
def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context: nil, hooks: [], hook_hints: nil)
evaluation_details = fetch_details(type: :#{result_type}, flag_key:, default_value:, evaluation_context:, invocation_hooks: hooks, hook_hints: hook_hints)
#{"evaluation_details.value" if suffix == :value}
end
RUBY
Expand All @@ -53,20 +51,62 @@ def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context

private

def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil)
def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil)
validate_default_value_type(type, default_value)

built_context = EvaluationContextBuilder.new.call(api_context: OpenFeature::SDK.evaluation_context, client_context: self.evaluation_context, invocation_context: evaluation_context)
built_context = EvaluationContextBuilder.new.call(
api_context: OpenFeature::SDK.evaluation_context,
client_context: self.evaluation_context,
invocation_context: evaluation_context
)

resolution_details = @provider.send(:"fetch_#{type}_value", flag_key:, default_value:, evaluation_context: built_context)
# Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
ordered_hooks = [*OpenFeature::SDK.hooks, *@hooks, *invocation_hooks, *provider_hooks]

# Fast path: skip hook ceremony when no hooks are registered
if ordered_hooks.empty?
return evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
end

hook_context = Hooks::HookContext.new(
flag_key: flag_key,
flag_value_type: type,
default_value: default_value,
evaluation_context: built_context,
client_metadata: @metadata,
provider_metadata: @provider.respond_to?(:metadata) ? @provider.metadata : nil
)

hints = if hook_hints.is_a?(Hooks::Hints)
hook_hints
elsif hook_hints
Hooks::Hints.new(hook_hints)
else
EMPTY_HINTS
end

executor = Hooks::HookExecutor.new(logger: OpenFeature::SDK.configuration.logger)
executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
end
end

def evaluate_flag(type:, flag_key:, default_value:, evaluation_context:)
resolution_details = @provider.send(
:"fetch_#{type}_value",
flag_key: flag_key,
default_value: default_value,
evaluation_context: evaluation_context
)

if TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
resolution_details.value = default_value
resolution_details.error_code = Provider::ErrorCode::TYPE_MISMATCH
resolution_details.reason = Provider::Reason::ERROR
end

EvaluationDetails.new(flag_key:, resolution_details:)
EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
end

def validate_default_value_type(type, default_value)
Expand Down
6 changes: 6 additions & 0 deletions lib/open_feature/sdk/hooks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

require_relative "hooks/hints"
require_relative "hooks/hook"
require_relative "hooks/hook_context"
require_relative "hooks/hook_executor"
2 changes: 2 additions & 0 deletions lib/open_feature/sdk/hooks/hints.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "delegate"

module OpenFeature
module SDK
module Hooks
Expand Down
34 changes: 34 additions & 0 deletions lib/open_feature/sdk/hooks/hook.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module OpenFeature
module SDK
module Hooks
# Module that hooks include. Provides default no-op implementations
# for all four lifecycle stages. A hook overrides the stages it cares about.
#
# Spec 4.3.1: Hooks MUST specify at least one stage.
module Hook
# Called before flag evaluation. May return an EvaluationContext
# that gets merged into the existing context (spec 4.3.2.1, 4.3.4, 4.3.5).
def before(hook_context:, hints:)
nil
end

# Called after successful flag evaluation (spec 4.3.3).
def after(hook_context:, evaluation_details:, hints:)
nil
end

# Called when an error occurs during flag evaluation (spec 4.3.6).
def error(hook_context:, exception:, hints:)
nil
end

# Called unconditionally after flag evaluation (spec 4.3.7).
def finally(hook_context:, evaluation_details:, hints:)
nil
end
end
end
end
end
29 changes: 29 additions & 0 deletions lib/open_feature/sdk/hooks/hook_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

module OpenFeature
module SDK
module Hooks
# Provides context to hook stages during flag evaluation.
#
# Per spec 4.1.1-4.1.5:
# - flag_key, flag_value_type, default_value are immutable (4.1.3)
# - client_metadata, provider_metadata are optional (4.1.2)
# - evaluation_context is mutable (for before hooks to modify, 4.1.4.1)
class HookContext
attr_reader :flag_key, :flag_value_type, :default_value,
:client_metadata, :provider_metadata
attr_accessor :evaluation_context

def initialize(flag_key:, flag_value_type:, default_value:, evaluation_context:,
client_metadata: nil, provider_metadata: nil)
@flag_key = flag_key.freeze
@flag_value_type = flag_value_type.freeze
@default_value = default_value.freeze
@evaluation_context = evaluation_context
@client_metadata = client_metadata
@provider_metadata = provider_metadata
end
end
end
end
end
102 changes: 102 additions & 0 deletions lib/open_feature/sdk/hooks/hook_executor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

module OpenFeature
module SDK
module Hooks
# Orchestrates the full hook lifecycle for flag evaluation.
#
# Hook execution order (spec 4.4.2):
# Before: API → Client → Invocation → Provider
# After/Error/Finally: Provider → Invocation → Client → API (reverse)
#
# Error handling (spec 4.4.3-4.4.7):
# - Before/after hook error → stop remaining hooks, run error hooks, return default
# - Error hook error → log, continue remaining error hooks
# - Finally hook error → log, continue remaining finally hooks
class HookExecutor
def initialize(logger: nil)
@logger = logger
end

# Executes the full hook lifecycle around the flag evaluation block.
#
# @param ordered_hooks [Array] hooks in before-order (API, Client, Invocation, Provider)
# @param hook_context [HookContext] the hook context
# @param hints [Hints] hook hints
# @param evaluate_block [Proc] the flag evaluation to wrap
# @return [EvaluationDetails] the evaluation result
def execute(ordered_hooks:, hook_context:, hints:, &evaluate_block)
evaluation_details = nil

begin
run_before_hooks(ordered_hooks, hook_context, hints)
evaluation_details = evaluate_block.call(hook_context)
run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
rescue => e
run_error_hooks(ordered_hooks, hook_context, e, hints)

evaluation_details = EvaluationDetails.new(
flag_key: hook_context.flag_key,
resolution_details: Provider::ResolutionDetails.new(
value: hook_context.default_value,
error_code: Provider::ErrorCode::GENERAL,
reason: Provider::Reason::ERROR,
error_message: e.message
)
)
ensure
run_finally_hooks(ordered_hooks, hook_context, evaluation_details, hints)
end

evaluation_details
end

private

# Spec 4.4.2: Before hooks run in order: API → Client → Invocation → Provider
# Spec 4.3.4/4.3.5: If a before hook returns an EvaluationContext, it is merged
# into the existing context for subsequent hooks and evaluation.
def run_before_hooks(hooks, hook_context, hints)
hooks.each do |hook|
next unless hook.respond_to?(:before)
result = hook.before(hook_context: hook_context, hints: hints)
if result.is_a?(EvaluationContext)
existing = hook_context.evaluation_context
hook_context.evaluation_context = existing ? existing.merge(result) : result
end
end
end

# Spec 4.4.2: After hooks run in reverse order: Provider → Invocation → Client → API
def run_after_hooks(hooks, hook_context, evaluation_details, hints)
hooks.reverse_each do |hook|
next unless hook.respond_to?(:after)
hook.after(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
end
end

# Spec 4.4.4: Error hooks run in reverse order.
# If an error hook itself errors, log and continue remaining error hooks.
def run_error_hooks(hooks, hook_context, exception, hints)
hooks.reverse_each do |hook|
next unless hook.respond_to?(:error)
hook.error(hook_context: hook_context, exception: exception, hints: hints)
rescue => e
@logger&.error("Error hook #{hook.class.name} failed: #{e.message}")
end
end

# Spec 4.4.3: Finally hooks run in reverse order unconditionally.
# If a finally hook errors, log and continue remaining finally hooks.
def run_finally_hooks(hooks, hook_context, evaluation_details, hints)
hooks.reverse_each do |hook|
next unless hook.respond_to?(:finally)
hook.finally(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
rescue => e
@logger&.error("Finally hook #{hook.class.name} failed: #{e.message}")
end
end
end
end
end
end
44 changes: 44 additions & 0 deletions spec/open_feature/sdk/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -431,5 +431,49 @@
end
end
end

context "Hook hints" do
let(:capturing_hook) do
captured = nil
hook = Class.new do
include OpenFeature::SDK::Hooks::Hook

define_method(:before) do |hook_context:, hints:|
captured = hints
nil
end
end.new
[hook, -> { captured }]
end

it "accepts a Hash as hook_hints and converts it to Hints" do
hook, get_hints = capturing_hook

client.fetch_boolean_value(
flag_key: "test-flag",
default_value: false,
hooks: [hook],
hook_hints: {key: "value"}
)

expect(get_hints.call).to be_a(OpenFeature::SDK::Hooks::Hints)
expect(get_hints.call[:key]).to eq("value")
end

it "passes through a Hints instance directly" do
hook, get_hints = capturing_hook
hints = OpenFeature::SDK::Hooks::Hints.new(source: "direct")

client.fetch_boolean_value(
flag_key: "test-flag",
default_value: false,
hooks: [hook],
hook_hints: hints
)

expect(get_hints.call).to be(hints)
expect(get_hints.call[:source]).to eq("direct")
end
end
end
end
Loading
Loading