Skip to content

Commit

Permalink
feat!: Add EvaluationContext helpers and context merging to flag ev…
Browse files Browse the repository at this point in the history
…aluation (#119)

Signed-off-by: Max VelDink <[email protected]>
  • Loading branch information
maxveldink authored Apr 5, 2024
1 parent 6895a0d commit 34e4795
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 133 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,18 @@ OpenFeature::SDK.configure do |config|
))
# alternatively, you can bind multiple providers to different domains
config.set_provider(OpenFeature::SDK::Provider::NoOpProvider.new, domain: "legacy_flags")
# you can set a global evaluation context here
config.evaluation_context = OpenFeature::SDK::EvaluationContext.new("host" => "myhost.com")
end

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

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

# get an object value
object = client.fetch_object_value(flag_key: 'object_value', default_value: JSON.dump({ name: 'object'}))

# Invocation evaluation context can also be passed in during flag evaluation.
# During flag evaluation, invocation context takes precedence over client context
# which takes precedence over API (aka global) context.
bool_value = client.fetch_boolean_value(
flag_key: 'boolean_flag',
default_value: false,
evaluation_context: OpenFeature::SDK::EvaluationContext.new("is_friday" => true)
)
```

For complete documentation, visit: https://openfeature.dev/docs/category/concepts
Expand Down
9 changes: 5 additions & 4 deletions lib/open_feature/sdk/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

require_relative "configuration"
require_relative "evaluation_context"
require_relative "evaluation_context_builder"
require_relative "evaluation_details"
require_relative "client_metadata"
require_relative "client"
Expand All @@ -31,7 +32,7 @@ class API
include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1
extend Forwardable

def_delegators :configuration, :provider, :set_provider, :hooks, :context
def_delegators :configuration, :provider, :set_provider, :hooks, :evaluation_context

def configuration
@configuration ||= Configuration.new
Expand All @@ -43,12 +44,12 @@ def configure(&block)
block.call(configuration)
end

def build_client(name: nil, version: nil, domain: nil)
def build_client(domain: nil, evaluation_context: nil)
active_provider = provider(domain:).nil? ? Provider::NoOpProvider.new : provider(domain:)

Client.new(provider: active_provider, domain:, context:)
Client.new(provider: active_provider, domain:, evaluation_context:)
rescue
Client.new(provider: Provider::NoOpProvider.new)
Client.new(provider: Provider::NoOpProvider.new, evaluation_context:)
end
end
end
Expand Down
9 changes: 5 additions & 4 deletions lib/open_feature/sdk/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ class Client
RESULT_TYPE = %i[boolean string number object].freeze
SUFFIXES = %i[value details].freeze

attr_reader :metadata
attr_reader :metadata, :evaluation_context

attr_accessor :hooks

def initialize(provider:, domain: nil, context: nil)
def initialize(provider:, domain: nil, evaluation_context: nil)
@provider = provider
@metadata = ClientMetadata.new(domain:)
@context = context
@evaluation_context = evaluation_context
@hooks = []
end

Expand All @@ -26,7 +26,8 @@ def initialize(provider:, domain: nil, 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)
resolution_details = @provider.fetch_#{result_type}_value(flag_key:, default_value:, 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.fetch_#{result_type}_value(flag_key:, default_value:, evaluation_context: built_context)
evaluation_details = EvaluationDetails.new(flag_key:, resolution_details:)
#{"evaluation_details.value" if suffix == :value}
end
Expand Down
4 changes: 2 additions & 2 deletions lib/open_feature/sdk/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
module OpenFeature
module SDK
# Represents the configuration object for the global API where <tt>Provider</tt>, <tt>Hook</tt>,
# and <tt>Context</tt> are configured.
# and <tt>EvaluationContext</tt> are configured.
# This class is not meant to be interacted with directly but instead through the <tt>OpenFeature::SDK.configure</tt>
# method
class Configuration
extend Forwardable

attr_accessor :context, :hooks
attr_accessor :evaluation_context, :hooks

def initialize
@hooks = []
Expand Down
15 changes: 13 additions & 2 deletions lib/open_feature/sdk/evaluation_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ class EvaluationContext

attr_reader :fields

def initialize(targeting_key: nil, **fields)
@fields = {TARGETING_KEY => targeting_key}.merge(fields)
def initialize(**fields)
@fields = fields.transform_keys(&:to_s)
end

def targeting_key
Expand All @@ -16,6 +16,17 @@ def targeting_key
def field(key)
fields[key]
end

def merge(overriding_context)
EvaluationContext.new(
targeting_key: overriding_context.targeting_key || targeting_key,
**fields.merge(overriding_context.fields)
)
end

def ==(other)
fields == other.fields
end
end
end
end
16 changes: 16 additions & 0 deletions lib/open_feature/sdk/evaluation_context_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module OpenFeature
module SDK
# Used to combine evaluation contexts from different sources
class EvaluationContextBuilder
def call(api_context:, client_context:, invocation_context:)
available_contexts = [api_context, client_context, invocation_context].compact

return nil if available_contexts.empty?

available_contexts.reduce(EvaluationContext.new) do |built_context, context|
built_context.merge(context)
end
end
end
end
end
118 changes: 0 additions & 118 deletions spec/open_feature/sdk/api_spec.rb

This file was deleted.

74 changes: 74 additions & 0 deletions spec/open_feature/sdk/evaluation_context_builder_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
require "spec_helper"

RSpec.describe OpenFeature::SDK::EvaluationContextBuilder do
let(:builder) { described_class.new }
let(:api_context) { OpenFeature::SDK::EvaluationContext.new("targeting_key" => "api", "api" => "key") }
let(:client_context) { OpenFeature::SDK::EvaluationContext.new("targeting_key" => "client", "client" => "key") }
let(:invocation_context) { OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "invocation" => "key") }

describe "#call" do
context "when no available contexts" do
it "returns nil" do
result = builder.call(api_context: nil, client_context: nil, invocation_context: nil)

expect(result).to be_nil
end
end

context "when only api context" do
it "returns api context" do
result = builder.call(api_context:, client_context: nil, invocation_context: nil)

expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "api", "api" => "key"))
end
end

context "when only client context" do
it "returns client context" do
result = builder.call(api_context: nil, client_context:, invocation_context: nil)

expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "client", "client" => "key"))
end
end

context "when only invocation context" do
it "returns invocation context" do
result = builder.call(api_context: nil, client_context: nil, invocation_context:)

expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "invocation" => "key"))
end
end

context "when api and client contexts" do
it "returns merged context" do
result = builder.call(api_context:, client_context:, invocation_context: nil)

expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "client", "api" => "key", "client" => "key"))
end
end

context "when client and invocation contexts" do
it "returns merged context" do
result = builder.call(api_context: nil, client_context:, invocation_context:)

expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "client" => "key", "invocation" => "key"))
end
end

context "when global and invocation contexts" do
it "returns merged context" do
result = builder.call(api_context:, client_context: nil, invocation_context:)

expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "api" => "key", "invocation" => "key"))
end
end

context "when all contexts" do
it "returns merged context" do
result = builder.call(api_context:, client_context:, invocation_context:)

expect(result).to eq(OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "api" => "key", "client" => "key", "invocation" => "key"))
end
end
end
end
27 changes: 27 additions & 0 deletions spec/open_feature/sdk/evaluation_context_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
require "spec_helper"

RSpec.describe OpenFeature::SDK::EvaluationContext do
let(:evaluation_context) { described_class.new("targeting_key" => "base", "favorite_fruit" => "apple") }

describe "#merge" do
context "when key exists in overriding context" do
it "overrides" do
overriding_context = described_class.new("targeting_key" => "new", "favorite_fruit" => "banana", "favorite_day" => "Monday")

new_context = evaluation_context.merge(overriding_context)

expect(new_context).to eq(described_class.new("targeting_key" => "new", "favorite_fruit" => "banana", "favorite_day" => "Monday"))
end
end

context "when new keys exist in overwriting context" do
it "merges" do
overriding_context = described_class.new("favorite_day" => "Monday")

new_context = evaluation_context.merge(overriding_context)

expect(new_context).to eq(described_class.new("targeting_key" => "base", "favorite_fruit" => "apple", "favorite_day" => "Monday"))
end
end
end
end
Loading

0 comments on commit 34e4795

Please sign in to comment.