From bcabfa5cc64cadb946e848318cde9695d87c76e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Sch=C3=B6nlaub?= Date: Sun, 3 Sep 2023 14:20:59 -0600 Subject: [PATCH] feat: adds evaluation context merging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Manuel Schönlaub --- lib/openfeature/sdk/client.rb | 5 +- lib/openfeature/sdk/evaluation/context.rb | 48 ++++++++++ .../sdk/evaluation/context_spec.rb | 91 +++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 lib/openfeature/sdk/evaluation/context.rb create mode 100644 spec/openfeature/sdk/evaluation/context_spec.rb diff --git a/lib/openfeature/sdk/client.rb b/lib/openfeature/sdk/client.rb index b05de91..991d0d1 100644 --- a/lib/openfeature/sdk/client.rb +++ b/lib/openfeature/sdk/client.rb @@ -12,7 +12,7 @@ class Client attr_accessor :hooks - def initialize(provider:, client_options: nil, context: nil) + def initialize(provider:, client_options: nil, context: {}) @provider = provider @metadata = client_options @context = context @@ -25,7 +25,8 @@ def initialize(provider:, client_options: nil, context: nil) # 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) + def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context: {}) + evaluation_context = OpenFeature::SDK.context.merge(@context).merge(evaluation_context) result = @provider.fetch_#{result_type}_value(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context) #{"result.value" if suffix == :value} end diff --git a/lib/openfeature/sdk/evaluation/context.rb b/lib/openfeature/sdk/evaluation/context.rb new file mode 100644 index 0000000..6dead9f --- /dev/null +++ b/lib/openfeature/sdk/evaluation/context.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module OpenFeature + module SDK + module Evaluation + # A container for arbitrary contextual data that can be used as a basis for dynamic evaluation + class Context < SimpleDelegator + + def initialize(context = Concurrent::Hash.new({})) + raise ArgumentError, "context must be a Hash" unless context.is_a?(Hash) + + context = Concurrent::Hash[context] unless context.is_a?(Concurrent::Hash) + super(context) + end + + def freeze + super + deep_freeze + end + + def targeting_key + self[:targeting_key] + end + + def targeting_key=(value) + raise ArgumentError, "targeting_key must be a String" unless value.is_a?(String) + + self[:targeting_key] = value + end + + private + + def deep_freeze + hashes = values.select { |value| value.is_a?(Hash) } + while hashes.empty? == false + hash = hashes.pop + hash.freeze unless hash.frozen? + hash.each do |key, value| + key.freeze unless key.frozen? + value.freeze unless value.frozen? + hashes << value if value.is_a?(Hash) + end + end + end + end + end + end +end diff --git a/spec/openfeature/sdk/evaluation/context_spec.rb b/spec/openfeature/sdk/evaluation/context_spec.rb new file mode 100644 index 0000000..e5271b6 --- /dev/null +++ b/spec/openfeature/sdk/evaluation/context_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require_relative "../../../spec_helper" + +require "openfeature/sdk/evaluation/context" +require "date" + +# https://openfeature.dev/specification/sections/evaluation-context +RSpec.describe OpenFeature::SDK::Evaluation::Context do + subject(:evaluation_context) { described_class.new({ targeting_key: "targeting_key", custom_field: "abc" }) } + context "3.1 Fields" do + context "Requirement 3.1.1" do + it "MUST define an optional targeting key field of type string, identifying the subject of the flag evaluation." do + expect(evaluation_context).to respond_to(:targeting_key) + expect(evaluation_context).to respond_to(:targeting_key=) + expect(evaluation_context.targeting_key).to be_a(String) + end + end + + context "Requirement 3.1.2" do + context "MUST support the inclusion of custom fields, having keys of type string, and values of type boolean | string | number | datetime | structure." do + context "boolean" do + it do + expect(evaluation_context[:boolean_key] = true).to eq(true) + expect(evaluation_context[:boolean_key]).to be_a(TrueClass) + end + end + context "string" do + it do + expect(evaluation_context[:string_key] = "string_value").to eq("string_value") + expect(evaluation_context[:string_key]).to be_a(String) + end + end + context "number" do + it do + expect(evaluation_context[:number_key] = 1).to eq(1) + expect(evaluation_context[:number_key]).to be_a(Integer) + end + end + context "datetime" do + it do + expect(evaluation_context[:datetime_key] = DateTime.now).to be_a(DateTime) + expect(evaluation_context[:datetime_key]).to be_a(DateTime) + end + end + context "structure" do + it do + expect(evaluation_context[:structure_key] = { key: "value" }).to eq({ key: "value" }) + expect(evaluation_context[:structure_key]).to be_a(Hash) + end + end + end + end + + context "Requirement 3.1.3" do + it "MUST support fetching the custom fields by key and also fetching all key value pairs." do + expect(evaluation_context).to respond_to(:to_h) + expect(evaluation_context.to_h).to eq({ targeting_key: "targeting_key", custom_field: "abc" }) + end + end + + context "Requirement 3.1.4" do + it "MUST have an unique key." do + evaluation_context[:key] = "value" + expect(evaluation_context[:key]).to eq("value") + evaluation_context[:key] = "new_value" + expect(evaluation_context[:key]).to eq("new_value") + expect(evaluation_context.keys).to eq(evaluation_context.keys.uniq) + end + end + + context "Requirement 3.2.2" do + let(:provider) { instance_spy("NoOpProvider") } + + it "MUST be merged in the order: API (global; lowest precedence) -> client -> invocation -> before hooks (highest precedence), with duplicate values being overwritten" do + api_context = described_class.new({ a: "api_value", b: "api_value" }) + client_context = described_class.new({ c: "client_value", b: "client_value" }) + invocation_context = described_class.new({ d: "invocation_value", b: "invocation_value" }) + + OpenFeature::SDK.configure do |config| + config.context = api_context + end + expected_context = { a: "api_value", c: "client_value", d: "invocation_value", b: "invocation_value" } + + client = OpenFeature::SDK::Client.new(provider: provider, context: client_context) + client.fetch_boolean_value(flag_key: "flag_key", default_value: false, evaluation_context: invocation_context) + expect(provider).to have_received(:fetch_boolean_value).with(flag_key: "flag_key", default_value: false, evaluation_context: expected_context) + end + end + end +end