Skip to content

Commit 41c3b9e

Browse files
josecolellaclaude
andauthored
feat: implement hooks lifecycle (spec section 4) (#214)
Signed-off-by: Jose Colella <jose.colella@gusto.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8ad2dde commit 41c3b9e

14 files changed

Lines changed: 1268 additions & 12 deletions

File tree

lib/open_feature/sdk/api.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require_relative "evaluation_context_builder"
99
require_relative "evaluation_details"
1010
require_relative "client_metadata"
11+
require_relative "hooks"
1112
require_relative "client"
1213
require_relative "provider"
1314

lib/open_feature/sdk/client.rb

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class Client
1515
}.freeze
1616
RESULT_TYPE = TYPE_CLASS_MAP.keys.freeze
1717
SUFFIXES = %i[value details].freeze
18+
EMPTY_HINTS = Hooks::Hints.new.freeze
1819

1920
attr_reader :metadata, :evaluation_context
2021

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

5452
private
5553

56-
def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil)
54+
def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil)
5755
validate_default_value_type(type, default_value)
5856

59-
built_context = EvaluationContextBuilder.new.call(api_context: OpenFeature::SDK.evaluation_context, client_context: self.evaluation_context, invocation_context: evaluation_context)
57+
built_context = EvaluationContextBuilder.new.call(
58+
api_context: OpenFeature::SDK.evaluation_context,
59+
client_context: self.evaluation_context,
60+
invocation_context: evaluation_context
61+
)
6062

61-
resolution_details = @provider.send(:"fetch_#{type}_value", flag_key:, default_value:, evaluation_context: built_context)
63+
# Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
64+
provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
65+
ordered_hooks = [*OpenFeature::SDK.hooks, *@hooks, *invocation_hooks, *provider_hooks]
66+
67+
# Fast path: skip hook ceremony when no hooks are registered
68+
if ordered_hooks.empty?
69+
return evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
70+
end
71+
72+
hook_context = Hooks::HookContext.new(
73+
flag_key: flag_key,
74+
flag_value_type: type,
75+
default_value: default_value,
76+
evaluation_context: built_context,
77+
client_metadata: @metadata,
78+
provider_metadata: @provider.respond_to?(:metadata) ? @provider.metadata : nil
79+
)
80+
81+
hints = if hook_hints.is_a?(Hooks::Hints)
82+
hook_hints
83+
elsif hook_hints
84+
Hooks::Hints.new(hook_hints)
85+
else
86+
EMPTY_HINTS
87+
end
88+
89+
executor = Hooks::HookExecutor.new(logger: OpenFeature::SDK.configuration.logger)
90+
executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
91+
evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
92+
end
93+
end
94+
95+
def evaluate_flag(type:, flag_key:, default_value:, evaluation_context:)
96+
resolution_details = @provider.send(
97+
:"fetch_#{type}_value",
98+
flag_key: flag_key,
99+
default_value: default_value,
100+
evaluation_context: evaluation_context
101+
)
62102

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

69-
EvaluationDetails.new(flag_key:, resolution_details:)
109+
EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
70110
end
71111

72112
def validate_default_value_type(type, default_value)

lib/open_feature/sdk/hooks.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "hooks/hints"
4+
require_relative "hooks/hook"
5+
require_relative "hooks/hook_context"
6+
require_relative "hooks/hook_executor"

lib/open_feature/sdk/hooks/hints.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require "delegate"
4+
35
module OpenFeature
46
module SDK
57
module Hooks

lib/open_feature/sdk/hooks/hook.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
3+
module OpenFeature
4+
module SDK
5+
module Hooks
6+
# Module that hooks include. Provides default no-op implementations
7+
# for all four lifecycle stages. A hook overrides the stages it cares about.
8+
#
9+
# Spec 4.3.1: Hooks MUST specify at least one stage.
10+
module Hook
11+
# Called before flag evaluation. May return an EvaluationContext
12+
# that gets merged into the existing context (spec 4.3.2.1, 4.3.4, 4.3.5).
13+
def before(hook_context:, hints:)
14+
nil
15+
end
16+
17+
# Called after successful flag evaluation (spec 4.3.3).
18+
def after(hook_context:, evaluation_details:, hints:)
19+
nil
20+
end
21+
22+
# Called when an error occurs during flag evaluation (spec 4.3.6).
23+
def error(hook_context:, exception:, hints:)
24+
nil
25+
end
26+
27+
# Called unconditionally after flag evaluation (spec 4.3.7).
28+
def finally(hook_context:, evaluation_details:, hints:)
29+
nil
30+
end
31+
end
32+
end
33+
end
34+
end
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
module OpenFeature
4+
module SDK
5+
module Hooks
6+
# Provides context to hook stages during flag evaluation.
7+
#
8+
# Per spec 4.1.1-4.1.5:
9+
# - flag_key, flag_value_type, default_value are immutable (4.1.3)
10+
# - client_metadata, provider_metadata are optional (4.1.2)
11+
# - evaluation_context is mutable (for before hooks to modify, 4.1.4.1)
12+
class HookContext
13+
attr_reader :flag_key, :flag_value_type, :default_value,
14+
:client_metadata, :provider_metadata
15+
attr_accessor :evaluation_context
16+
17+
def initialize(flag_key:, flag_value_type:, default_value:, evaluation_context:,
18+
client_metadata: nil, provider_metadata: nil)
19+
@flag_key = flag_key.freeze
20+
@flag_value_type = flag_value_type.freeze
21+
@default_value = default_value.freeze
22+
@evaluation_context = evaluation_context
23+
@client_metadata = client_metadata
24+
@provider_metadata = provider_metadata
25+
end
26+
end
27+
end
28+
end
29+
end
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# frozen_string_literal: true
2+
3+
module OpenFeature
4+
module SDK
5+
module Hooks
6+
# Orchestrates the full hook lifecycle for flag evaluation.
7+
#
8+
# Hook execution order (spec 4.4.2):
9+
# Before: API → Client → Invocation → Provider
10+
# After/Error/Finally: Provider → Invocation → Client → API (reverse)
11+
#
12+
# Error handling (spec 4.4.3-4.4.7):
13+
# - Before/after hook error → stop remaining hooks, run error hooks, return default
14+
# - Error hook error → log, continue remaining error hooks
15+
# - Finally hook error → log, continue remaining finally hooks
16+
class HookExecutor
17+
def initialize(logger: nil)
18+
@logger = logger
19+
end
20+
21+
# Executes the full hook lifecycle around the flag evaluation block.
22+
#
23+
# @param ordered_hooks [Array] hooks in before-order (API, Client, Invocation, Provider)
24+
# @param hook_context [HookContext] the hook context
25+
# @param hints [Hints] hook hints
26+
# @param evaluate_block [Proc] the flag evaluation to wrap
27+
# @return [EvaluationDetails] the evaluation result
28+
def execute(ordered_hooks:, hook_context:, hints:, &evaluate_block)
29+
evaluation_details = nil
30+
31+
begin
32+
run_before_hooks(ordered_hooks, hook_context, hints)
33+
evaluation_details = evaluate_block.call(hook_context)
34+
run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
35+
rescue => e
36+
run_error_hooks(ordered_hooks, hook_context, e, hints)
37+
38+
evaluation_details = EvaluationDetails.new(
39+
flag_key: hook_context.flag_key,
40+
resolution_details: Provider::ResolutionDetails.new(
41+
value: hook_context.default_value,
42+
error_code: Provider::ErrorCode::GENERAL,
43+
reason: Provider::Reason::ERROR,
44+
error_message: e.message
45+
)
46+
)
47+
ensure
48+
run_finally_hooks(ordered_hooks, hook_context, evaluation_details, hints)
49+
end
50+
51+
evaluation_details
52+
end
53+
54+
private
55+
56+
# Spec 4.4.2: Before hooks run in order: API → Client → Invocation → Provider
57+
# Spec 4.3.4/4.3.5: If a before hook returns an EvaluationContext, it is merged
58+
# into the existing context for subsequent hooks and evaluation.
59+
def run_before_hooks(hooks, hook_context, hints)
60+
hooks.each do |hook|
61+
next unless hook.respond_to?(:before)
62+
result = hook.before(hook_context: hook_context, hints: hints)
63+
if result.is_a?(EvaluationContext)
64+
existing = hook_context.evaluation_context
65+
hook_context.evaluation_context = existing ? existing.merge(result) : result
66+
end
67+
end
68+
end
69+
70+
# Spec 4.4.2: After hooks run in reverse order: Provider → Invocation → Client → API
71+
def run_after_hooks(hooks, hook_context, evaluation_details, hints)
72+
hooks.reverse_each do |hook|
73+
next unless hook.respond_to?(:after)
74+
hook.after(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
75+
end
76+
end
77+
78+
# Spec 4.4.4: Error hooks run in reverse order.
79+
# If an error hook itself errors, log and continue remaining error hooks.
80+
def run_error_hooks(hooks, hook_context, exception, hints)
81+
hooks.reverse_each do |hook|
82+
next unless hook.respond_to?(:error)
83+
hook.error(hook_context: hook_context, exception: exception, hints: hints)
84+
rescue => e
85+
@logger&.error("Error hook #{hook.class.name} failed: #{e.message}")
86+
end
87+
end
88+
89+
# Spec 4.4.3: Finally hooks run in reverse order unconditionally.
90+
# If a finally hook errors, log and continue remaining finally hooks.
91+
def run_finally_hooks(hooks, hook_context, evaluation_details, hints)
92+
hooks.reverse_each do |hook|
93+
next unless hook.respond_to?(:finally)
94+
hook.finally(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
95+
rescue => e
96+
@logger&.error("Finally hook #{hook.class.name} failed: #{e.message}")
97+
end
98+
end
99+
end
100+
end
101+
end
102+
end

spec/open_feature/sdk/client_spec.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,5 +431,49 @@
431431
end
432432
end
433433
end
434+
435+
context "Hook hints" do
436+
let(:capturing_hook) do
437+
captured = nil
438+
hook = Class.new do
439+
include OpenFeature::SDK::Hooks::Hook
440+
441+
define_method(:before) do |hook_context:, hints:|
442+
captured = hints
443+
nil
444+
end
445+
end.new
446+
[hook, -> { captured }]
447+
end
448+
449+
it "accepts a Hash as hook_hints and converts it to Hints" do
450+
hook, get_hints = capturing_hook
451+
452+
client.fetch_boolean_value(
453+
flag_key: "test-flag",
454+
default_value: false,
455+
hooks: [hook],
456+
hook_hints: {key: "value"}
457+
)
458+
459+
expect(get_hints.call).to be_a(OpenFeature::SDK::Hooks::Hints)
460+
expect(get_hints.call[:key]).to eq("value")
461+
end
462+
463+
it "passes through a Hints instance directly" do
464+
hook, get_hints = capturing_hook
465+
hints = OpenFeature::SDK::Hooks::Hints.new(source: "direct")
466+
467+
client.fetch_boolean_value(
468+
flag_key: "test-flag",
469+
default_value: false,
470+
hooks: [hook],
471+
hook_hints: hints
472+
)
473+
474+
expect(get_hints.call).to be(hints)
475+
expect(get_hints.call[:source]).to eq("direct")
476+
end
477+
end
434478
end
435479
end

0 commit comments

Comments
 (0)