Skip to content

Commit 34e4795

Browse files
authored
feat!: Add EvaluationContext helpers and context merging to flag evaluation (#119)
Signed-off-by: Max VelDink <maxveldink@gmail.com>
1 parent 6895a0d commit 34e4795

File tree

10 files changed

+203
-133
lines changed

10 files changed

+203
-133
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,18 @@ OpenFeature::SDK.configure do |config|
5050
))
5151
# alternatively, you can bind multiple providers to different domains
5252
config.set_provider(OpenFeature::SDK::Provider::NoOpProvider.new, domain: "legacy_flags")
53+
# you can set a global evaluation context here
54+
config.evaluation_context = OpenFeature::SDK::EvaluationContext.new("host" => "myhost.com")
5355
end
5456

5557
# Create a client
5658
client = OpenFeature::SDK.build_client
5759
# Create a client for a different domain, this will use the provider assigned to that domain
5860
legacy_flag_client = OpenFeature::SDK.build_client(domain: "legacy_flags")
61+
# Evaluation context can be set on a client as well
62+
client_with_context = OpenFeature::SDK.build_client(
63+
evaluation_context: OpenFeature::SDK::EvaluationContext.new("controller_name" => "admin")
64+
)
5965

6066
# fetching boolean value feature flag
6167
bool_value = client.fetch_boolean_value(flag_key: 'boolean_flag', default_value: false)
@@ -69,6 +75,15 @@ integer_value = client.fetch_number_value(flag_key: 'number_value', default_valu
6975

7076
# get an object value
7177
object = client.fetch_object_value(flag_key: 'object_value', default_value: JSON.dump({ name: 'object'}))
78+
79+
# Invocation evaluation context can also be passed in during flag evaluation.
80+
# During flag evaluation, invocation context takes precedence over client context
81+
# which takes precedence over API (aka global) context.
82+
bool_value = client.fetch_boolean_value(
83+
flag_key: 'boolean_flag',
84+
default_value: false,
85+
evaluation_context: OpenFeature::SDK::EvaluationContext.new("is_friday" => true)
86+
)
7287
```
7388

7489
For complete documentation, visit: https://openfeature.dev/docs/category/concepts

lib/open_feature/sdk/api.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
require_relative "configuration"
77
require_relative "evaluation_context"
8+
require_relative "evaluation_context_builder"
89
require_relative "evaluation_details"
910
require_relative "client_metadata"
1011
require_relative "client"
@@ -31,7 +32,7 @@ class API
3132
include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1
3233
extend Forwardable
3334

34-
def_delegators :configuration, :provider, :set_provider, :hooks, :context
35+
def_delegators :configuration, :provider, :set_provider, :hooks, :evaluation_context
3536

3637
def configuration
3738
@configuration ||= Configuration.new
@@ -43,12 +44,12 @@ def configure(&block)
4344
block.call(configuration)
4445
end
4546

46-
def build_client(name: nil, version: nil, domain: nil)
47+
def build_client(domain: nil, evaluation_context: nil)
4748
active_provider = provider(domain:).nil? ? Provider::NoOpProvider.new : provider(domain:)
4849

49-
Client.new(provider: active_provider, domain:, context:)
50+
Client.new(provider: active_provider, domain:, evaluation_context:)
5051
rescue
51-
Client.new(provider: Provider::NoOpProvider.new)
52+
Client.new(provider: Provider::NoOpProvider.new, evaluation_context:)
5253
end
5354
end
5455
end

lib/open_feature/sdk/client.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ class Client
88
RESULT_TYPE = %i[boolean string number object].freeze
99
SUFFIXES = %i[value details].freeze
1010

11-
attr_reader :metadata
11+
attr_reader :metadata, :evaluation_context
1212

1313
attr_accessor :hooks
1414

15-
def initialize(provider:, domain: nil, context: nil)
15+
def initialize(provider:, domain: nil, evaluation_context: nil)
1616
@provider = provider
1717
@metadata = ClientMetadata.new(domain:)
18-
@context = context
18+
@evaluation_context = evaluation_context
1919
@hooks = []
2020
end
2121

@@ -26,7 +26,8 @@ def initialize(provider:, domain: nil, context: nil)
2626
# result = @provider.fetch_boolean_value(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context)
2727
# end
2828
def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context: nil)
29-
resolution_details = @provider.fetch_#{result_type}_value(flag_key:, default_value:, evaluation_context:)
29+
built_context = EvaluationContextBuilder.new.call(api_context: OpenFeature::SDK.evaluation_context, client_context: self.evaluation_context, invocation_context: evaluation_context)
30+
resolution_details = @provider.fetch_#{result_type}_value(flag_key:, default_value:, evaluation_context: built_context)
3031
evaluation_details = EvaluationDetails.new(flag_key:, resolution_details:)
3132
#{"evaluation_details.value" if suffix == :value}
3233
end

lib/open_feature/sdk/configuration.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
module OpenFeature
66
module SDK
77
# Represents the configuration object for the global API where <tt>Provider</tt>, <tt>Hook</tt>,
8-
# and <tt>Context</tt> are configured.
8+
# and <tt>EvaluationContext</tt> are configured.
99
# This class is not meant to be interacted with directly but instead through the <tt>OpenFeature::SDK.configure</tt>
1010
# method
1111
class Configuration
1212
extend Forwardable
1313

14-
attr_accessor :context, :hooks
14+
attr_accessor :evaluation_context, :hooks
1515

1616
def initialize
1717
@hooks = []

lib/open_feature/sdk/evaluation_context.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ class EvaluationContext
55

66
attr_reader :fields
77

8-
def initialize(targeting_key: nil, **fields)
9-
@fields = {TARGETING_KEY => targeting_key}.merge(fields)
8+
def initialize(**fields)
9+
@fields = fields.transform_keys(&:to_s)
1010
end
1111

1212
def targeting_key
@@ -16,6 +16,17 @@ def targeting_key
1616
def field(key)
1717
fields[key]
1818
end
19+
20+
def merge(overriding_context)
21+
EvaluationContext.new(
22+
targeting_key: overriding_context.targeting_key || targeting_key,
23+
**fields.merge(overriding_context.fields)
24+
)
25+
end
26+
27+
def ==(other)
28+
fields == other.fields
29+
end
1930
end
2031
end
2132
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module OpenFeature
2+
module SDK
3+
# Used to combine evaluation contexts from different sources
4+
class EvaluationContextBuilder
5+
def call(api_context:, client_context:, invocation_context:)
6+
available_contexts = [api_context, client_context, invocation_context].compact
7+
8+
return nil if available_contexts.empty?
9+
10+
available_contexts.reduce(EvaluationContext.new) do |built_context, context|
11+
built_context.merge(context)
12+
end
13+
end
14+
end
15+
end
16+
end

spec/open_feature/sdk/api_spec.rb

Lines changed: 0 additions & 118 deletions
This file was deleted.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
require "spec_helper"
2+
3+
RSpec.describe OpenFeature::SDK::EvaluationContextBuilder do
4+
let(:builder) { described_class.new }
5+
let(:api_context) { OpenFeature::SDK::EvaluationContext.new("targeting_key" => "api", "api" => "key") }
6+
let(:client_context) { OpenFeature::SDK::EvaluationContext.new("targeting_key" => "client", "client" => "key") }
7+
let(:invocation_context) { OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "invocation" => "key") }
8+
9+
describe "#call" do
10+
context "when no available contexts" do
11+
it "returns nil" do
12+
result = builder.call(api_context: nil, client_context: nil, invocation_context: nil)
13+
14+
expect(result).to be_nil
15+
end
16+
end
17+
18+
context "when only api context" do
19+
it "returns api context" do
20+
result = builder.call(api_context:, client_context: nil, invocation_context: nil)
21+
22+
expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "api", "api" => "key"))
23+
end
24+
end
25+
26+
context "when only client context" do
27+
it "returns client context" do
28+
result = builder.call(api_context: nil, client_context:, invocation_context: nil)
29+
30+
expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "client", "client" => "key"))
31+
end
32+
end
33+
34+
context "when only invocation context" do
35+
it "returns invocation context" do
36+
result = builder.call(api_context: nil, client_context: nil, invocation_context:)
37+
38+
expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "invocation" => "key"))
39+
end
40+
end
41+
42+
context "when api and client contexts" do
43+
it "returns merged context" do
44+
result = builder.call(api_context:, client_context:, invocation_context: nil)
45+
46+
expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "client", "api" => "key", "client" => "key"))
47+
end
48+
end
49+
50+
context "when client and invocation contexts" do
51+
it "returns merged context" do
52+
result = builder.call(api_context: nil, client_context:, invocation_context:)
53+
54+
expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "client" => "key", "invocation" => "key"))
55+
end
56+
end
57+
58+
context "when global and invocation contexts" do
59+
it "returns merged context" do
60+
result = builder.call(api_context:, client_context: nil, invocation_context:)
61+
62+
expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "api" => "key", "invocation" => "key"))
63+
end
64+
end
65+
66+
context "when all contexts" do
67+
it "returns merged context" do
68+
result = builder.call(api_context:, client_context:, invocation_context:)
69+
70+
expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "api" => "key", "client" => "key", "invocation" => "key"))
71+
end
72+
end
73+
end
74+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
require "spec_helper"
2+
3+
RSpec.describe OpenFeature::SDK::EvaluationContext do
4+
let(:evaluation_context) { described_class.new("targeting_key" => "base", "favorite_fruit" => "apple") }
5+
6+
describe "#merge" do
7+
context "when key exists in overriding context" do
8+
it "overrides" do
9+
overriding_context = described_class.new("targeting_key" => "new", "favorite_fruit" => "banana", "favorite_day" => "Monday")
10+
11+
new_context = evaluation_context.merge(overriding_context)
12+
13+
expect(new_context).to eq(described_class.new("targeting_key" => "new", "favorite_fruit" => "banana", "favorite_day" => "Monday"))
14+
end
15+
end
16+
17+
context "when new keys exist in overwriting context" do
18+
it "merges" do
19+
overriding_context = described_class.new("favorite_day" => "Monday")
20+
21+
new_context = evaluation_context.merge(overriding_context)
22+
23+
expect(new_context).to eq(described_class.new("targeting_key" => "base", "favorite_fruit" => "apple", "favorite_day" => "Monday"))
24+
end
25+
end
26+
end
27+
end

0 commit comments

Comments
 (0)