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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,11 @@ class MyLoggingHook
end
```

Hooks can read their inputs but not change them:

- Only `before` hooks can change the evaluation context: return an `EvaluationContext` for the SDK to merge in, or assign `hook_context.evaluation_context`. By the time `after`, `error` and `finally` run, it's a frozen copy, so assigning it or writing to its `fields` raises `FrozenError`. Nested field values aren't frozen, because they belong to the caller.
- The flag key, the default value, the hints, and the client and provider metadata are frozen copies. Your own objects aren't frozen.

> Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs!

<!-- x-hide-in-docs-start -->
Expand Down
2 changes: 1 addition & 1 deletion lib/open_feature/sdk/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, inv
executor.run_short_circuit(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, evaluation_details: evaluation_details)
evaluation_details
else
executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, default_value:) do |hctx|
evaluate_flag(provider: provider, type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
end
end
Expand Down
10 changes: 10 additions & 0 deletions lib/open_feature/sdk/evaluation_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ def merge(overriding_context)
)
end

def initialize_copy(source)
super
@fields = @fields.dup
end

def freeze
fields.freeze
super
end

def ==(other)
fields == other.fields
end
Expand Down
14 changes: 14 additions & 0 deletions lib/open_feature/sdk/hooks/frozen_copy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module OpenFeature
module SDK
module Hooks
# A deep-frozen copy of a hook input, so hooks can't change it and the caller's object isn't frozen (spec 4.1.3, 4.2.2)
def self.frozen_copy(value)
Ractor.make_shareable(value, copy: true)
rescue
value.dup.freeze # shortcut: when something inside can't be copied (a Mutex, Queue or Proc), only the top level is frozen
end
end
end
end
4 changes: 2 additions & 2 deletions lib/open_feature/sdk/hooks/hints.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "delegate"
require_relative "frozen_copy"

module OpenFeature
module SDK
Expand All @@ -13,8 +14,7 @@ def initialize(hash = {})
assert_allowed_key(key)
assert_allowed_value(value)
end
@hash = hash.dup
super(@hash)
super(Hooks.frozen_copy(hash))
freeze
end

Expand Down
33 changes: 25 additions & 8 deletions lib/open_feature/sdk/hooks/hook_context.rb
Original file line number Diff line number Diff line change
@@ -1,28 +1,45 @@
# frozen_string_literal: true

require_relative "frozen_copy"

module OpenFeature
module SDK
module Hooks
# Provides context to hook stages during flag evaluation.
#
# Per spec 4.1.1-4.1.5:
# Per spec 4.1.1-4.1.5 and 4.2.2:
# - 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)
# - client_metadata, provider_metadata are optional (4.1.2) and immutable (4.2.2.2, 4.2.2.3)
# - evaluation_context is mutable only in before hooks; HookExecutor freezes the hook context after them (4.1.4.1)
class HookContext
attr_reader :flag_key, :flag_value_type, :default_value,
:client_metadata, :provider_metadata
attr_reader :flag_key, :flag_value_type, :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_key = Hooks.frozen_copy(flag_key)
@flag_value_type = flag_value_type.freeze
@default_value = default_value.freeze
@source_default_value = default_value
@evaluation_context = evaluation_context
@client_metadata = client_metadata
@provider_metadata = provider_metadata
@provider_metadata = Hooks.frozen_copy(provider_metadata)
@hook_data = {}
@copies = {}
end

# Copied on first read: most hooks never read it, and the copy grows with the value
def default_value
@copies.fetch(:default_value) { @copies[:default_value] = Hooks.frozen_copy(@source_default_value) }
end

# Swaps in a frozen copy of the evaluation context, so neither the caller's nor a before hook's object is frozen.
# Top-level strings are copied too, so a hook can't edit a targeting key in place.
def freeze
return self if frozen?
@evaluation_context &&= @evaluation_context.dup.tap do |copy|
copy.fields.transform_values! { |value| (value.is_a?(String) && !value.frozen?) ? value.dup.freeze : value }
end.freeze
super
end

# Returns a mutable hash scoped to the given hook instance.
Expand Down
11 changes: 9 additions & 2 deletions lib/open_feature/sdk/hooks/hook_executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ module Hooks
# - 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
#
# The hook context is frozen before after, error and finally hooks run, so only before hooks
# can change the evaluation context (spec 4.1.4.1). Not earlier: some providers write to it.
class HookExecutor
def initialize(logger: nil)
@logger = logger
Expand All @@ -37,9 +40,10 @@ def run_short_circuit(ordered_hooks:, hook_context:, hints:, evaluation_details:
# @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 default_value [Object] returned if evaluation raises; the caller's own object, not the hooks' frozen copy
# @param evaluate_block [Proc] the flag evaluation to wrap
# @return [EvaluationDetails] the evaluation result
def execute(ordered_hooks:, hook_context:, hints:, &evaluate_block)
def execute(ordered_hooks:, hook_context:, hints:, default_value:, &evaluate_block)
evaluation_details = nil

begin
Expand All @@ -60,7 +64,7 @@ def execute(ordered_hooks:, hook_context:, hints:, &evaluate_block)
evaluation_details = EvaluationDetails.new(
flag_key: hook_context.flag_key,
resolution_details: Provider::ResolutionDetails.new(
value: hook_context.default_value,
value: default_value,
error_code: EvaluationError.code_for(e),
reason: Provider::Reason::ERROR,
error_message: e.message
Expand Down Expand Up @@ -91,6 +95,7 @@ def run_before_hooks(hooks, hook_context, hints)

# Spec 4.4.2: After hooks run in reverse order: Provider → Invocation → Client → API
def run_after_hooks(hooks, hook_context, evaluation_details, hints)
hook_context.freeze
hooks.reverse_each do |hook|
next unless hook.respond_to?(:after)
hook.after(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
Expand All @@ -100,6 +105,7 @@ def run_after_hooks(hooks, hook_context, evaluation_details, hints)
# 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)
hook_context.freeze
hooks.reverse_each do |hook|
next unless hook.respond_to?(:error)
hook.error(hook_context: hook_context, exception: exception, hints: hints)
Expand All @@ -111,6 +117,7 @@ def run_error_hooks(hooks, hook_context, exception, hints)
# 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)
hook_context.freeze
hooks.reverse_each do |hook|
next unless hook.respond_to?(:finally)
hook.finally(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
Expand Down
4 changes: 4 additions & 0 deletions sig/open_feature/sdk/evaluation_context.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ module OpenFeature

def merge: (EvaluationContext overriding_context) -> EvaluationContext

def initialize_copy: (EvaluationContext source) -> void

def freeze: () -> self

def ==: (untyped other) -> bool
end
end
Expand Down
7 changes: 7 additions & 0 deletions sig/open_feature/sdk/hooks/frozen_copy.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module OpenFeature
module SDK
module Hooks
def self.frozen_copy: [T] (T value) -> T
end
end
end
5 changes: 4 additions & 1 deletion sig/open_feature/sdk/hooks/hook_context.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ module OpenFeature
class HookContext
attr_reader flag_key: String
attr_reader flag_value_type: Symbol
attr_reader default_value: untyped
attr_reader client_metadata: ClientMetadata?
attr_reader provider_metadata: Provider::ProviderMetadata?
attr_accessor evaluation_context: EvaluationContext?

def initialize: (flag_key: String, flag_value_type: Symbol, default_value: untyped, evaluation_context: EvaluationContext?, ?client_metadata: ClientMetadata?, ?provider_metadata: Provider::ProviderMetadata?) -> void

def default_value: () -> untyped

def freeze: () -> self

def hook_data_for: (untyped hook) -> Hash[untyped, untyped]
end
end
Expand Down
2 changes: 1 addition & 1 deletion sig/open_feature/sdk/hooks/hook_executor.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module OpenFeature

def run_short_circuit: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints, evaluation_details: EvaluationDetails) -> void

def execute: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints) { (HookContext) -> EvaluationDetails } -> EvaluationDetails?
def execute: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints, default_value: untyped) { (HookContext) -> EvaluationDetails } -> EvaluationDetails?

private

Expand Down
18 changes: 14 additions & 4 deletions spec/open_feature/sdk/hooks/hints_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
expect { hints[:new_key] = "new_value" }.to raise_error(FrozenError)
end

it "does allow modification of existing values" do
expect(hints[:key]).to_not be_frozen
expect { hints[:key] << "abc" }.to_not raise_error
it "does not allow modification of nested values" do
expect(hints[:nested][:key]).to be_frozen
expect { hints[:key] << "abc" }.to raise_error(FrozenError)
end

it "does not allow deletion of keys" do
Expand All @@ -41,8 +41,18 @@
end
end

it "does not freeze the original hash" do
it "does not freeze the original hash or its values" do
expect(hint_hash).not_to be_frozen
expect(hint_hash[:nested][:key]).not_to be_frozen
end

it "freezes only the top level when a nested value can't be copied" do
source = {key: {lock: Mutex.new}}
hints = described_class.new(source)

expect(hints[:key]).to eq(source[:key])
expect { hints[:other] = 1 }.to raise_error(FrozenError)
expect(source).not_to be_frozen
end
end
end
37 changes: 36 additions & 1 deletion spec/open_feature/sdk/hooks/hook_context_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
expect(ctx.default_value).to be_frozen
end

it "copies the default_value on first read, even once frozen" do
ctx = described_class.new(flag_key: "flag", flag_value_type: :object, default_value: {"list" => []}, evaluation_context: nil).freeze
expect(ctx.default_value["list"]).to be_frozen
end

it "exposes the default_value" do
expect(hook_context.default_value).to eq(false)
end
Expand Down Expand Up @@ -82,11 +87,41 @@
end
end

describe "mutable evaluation_context" do
describe "evaluation_context" do
it "allows setting evaluation_context" do
new_context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-456")
hook_context.evaluation_context = new_context
expect(hook_context.evaluation_context.targeting_key).to eq("user-456")
end

it "keeps the evaluation context's class once frozen" do
subclass = Class.new(OpenFeature::SDK::EvaluationContext) { def tenant = field("tenant") }
ctx = described_class.new(flag_key: "flag", flag_value_type: :boolean, default_value: false,
evaluation_context: subclass.new(tenant: "acme")).freeze

expect(ctx.evaluation_context.tenant).to eq("acme")
end

it "is a frozen copy once the hook context is frozen" do
hook_context.freeze

expect(hook_context.evaluation_context).to eq(evaluation_context)
expect(hook_context.evaluation_context.fields).to be_frozen
expect(evaluation_context).not_to be_frozen
expect { hook_context.evaluation_context = evaluation_context }.to raise_error(FrozenError)
end
end

describe "values that can't be copied" do
[Mutex.new, Thread::Queue.new, -> {}].each do |uncopyable|
it "freezes a shallow copy when it holds a #{uncopyable.class}" do
default = {held: uncopyable}
ctx = described_class.new(flag_key: "flag", flag_value_type: :object, default_value: default, evaluation_context: nil)

expect(ctx.default_value).to eq(default)
expect(ctx.default_value).to be_frozen
expect(default).not_to be_frozen
end
end
end
end
Loading
Loading