From 617c591693b03955d70be6a77aa5ef114129fa1f Mon Sep 17 00:00:00 2001 From: Jose Colella Date: Mon, 16 Mar 2026 10:35:35 -0700 Subject: [PATCH] feat(meta_provider): add multi-provider evaluation strategies Add FirstSuccessful, Comparison, and custom (Base) strategies to the MetaProvider, reaching parity with the JS/Go SDK implementations. Resolves open-feature/ruby-sdk#212. Changes: - Refactor inline strategy logic into Strategy pattern (Base, FirstMatch, FirstSuccessful, Comparison) - Add missing fetch_integer_value and fetch_float_value methods - Add track delegation to wrapped providers - Use define_method to DRY up the 6 fetch methods - Bump openfeature-sdk dependency to >= 0.4.0, < 1.0 - Bump version to 0.1.0 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Jose Colella --- .../openfeature-meta_provider/Gemfile.lock | 6 +- providers/openfeature-meta_provider/README.md | 66 ++++++++++++- .../lib/openfeature/meta_provider.rb | 92 ++++++++---------- .../meta_provider/strategy/base.rb | 41 ++++++++ .../meta_provider/strategy/comparison.rb | 38 ++++++++ .../meta_provider/strategy/first_match.rb | 24 +++++ .../strategy/first_successful.rb | 26 ++++++ .../lib/openfeature/meta_provider_version.rb | 2 +- .../openfeature-meta_provider.gemspec | 2 +- .../meta_provider/strategy/base_spec.rb | 37 ++++++++ .../meta_provider/strategy/comparison_spec.rb | 93 +++++++++++++++++++ .../strategy/first_match_spec.rb | 53 +++++++++++ .../strategy/first_successful_spec.rb | 82 ++++++++++++++++ .../spec/openfeature/meta_provider_spec.rb | 86 ++++++++++++++++- 14 files changed, 587 insertions(+), 61 deletions(-) create mode 100644 providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/base.rb create mode 100644 providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/comparison.rb create mode 100644 providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/first_match.rb create mode 100644 providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/first_successful.rb create mode 100644 providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/base_spec.rb create mode 100644 providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/comparison_spec.rb create mode 100644 providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/first_match_spec.rb create mode 100644 providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/first_successful_spec.rb diff --git a/providers/openfeature-meta_provider/Gemfile.lock b/providers/openfeature-meta_provider/Gemfile.lock index a6990e1..13afcd5 100644 --- a/providers/openfeature-meta_provider/Gemfile.lock +++ b/providers/openfeature-meta_provider/Gemfile.lock @@ -1,8 +1,8 @@ PATH remote: . specs: - openfeature-meta_provider (0.0.7) - openfeature-sdk (>= 0.3.0, <= 0.4) + openfeature-meta_provider (0.1.0) + openfeature-sdk (>= 0.4.0, < 1.0) GEM remote: https://rubygems.org/ @@ -20,7 +20,7 @@ GEM json (2.19.0) language_server-protocol (3.17.0.5) lint_roller (1.1.0) - openfeature-sdk (0.3.0) + openfeature-sdk (0.6.4) parallel (1.27.0) parser (3.3.10.2) ast (~> 2.4.1) diff --git a/providers/openfeature-meta_provider/README.md b/providers/openfeature-meta_provider/README.md index af29f2d..7502e6a 100644 --- a/providers/openfeature-meta_provider/README.md +++ b/providers/openfeature-meta_provider/README.md @@ -10,8 +10,14 @@ The `OpenFeature::MetaProvider` is a utility provider that wraps multiple [provi | ✅ | Boolean Flags | Evaluate boolean feature flags | | ✅ | String Flags | Evaluate string feature flags | | ✅ | Number Flags | Evaluate numeric feature flags | +| ✅ | Integer Flags | Evaluate integer feature flags | +| ✅ | Float Flags | Evaluate float feature flags | | ✅ | Object Flags | Evaluate object feature flags | | ✅ | First Match Strategy | Return the first successful resolution across providers | +| ✅ | First Successful Strategy | Skip FLAG_NOT_FOUND, stop on other errors | +| ✅ | Comparison Strategy | Evaluate all providers and check for unanimity | +| ✅ | Custom Strategies | Subclass `Strategy::Base` for custom resolution logic | +| ✅ | Track Delegation | Delegates `track` calls to all providers that support it | | ✅ | Provider Metadata | Tracks which provider matched via `flag_metadata` | | ✅ | Lifecycle Management | Delegates `init` and `shutdown` to all wrapped providers | @@ -107,7 +113,7 @@ The provider constructor accepts the following parameters: | Option | Type | Default | Required | Description | |--------|------|---------|----------|-------------| | `providers` | Array | - | **Yes** | An ordered array of provider instances to delegate to | -| `strategy` | Symbol | `:first_match` | No | The resolution strategy to use | +| `strategy` | Symbol or Strategy::Base | `:first_match` | No | The resolution strategy to use (see below) | ## Strategies @@ -119,6 +125,64 @@ When `:first_match` is given as the strategy, each provider is evaluated in the - If a provider returns a resolution with an error code, it is skipped and the next provider is tried. - If no provider returns a successful resolution, the default value is returned with an `ERROR` reason and a `GENERAL` error code. +### `:first_successful` + +Similar to `:first_match`, but distinguishes between "flag not found" and other errors: + +- If a provider returns `FLAG_NOT_FOUND`, it is skipped (the provider doesn't own that flag). +- If a provider returns any other error code (`PARSE_ERROR`, `TYPE_MISMATCH`, etc.), evaluation **stops** — the provider owns the flag but failed to resolve it. +- If a provider raises an exception, evaluation **stops** with the error details. +- If all providers return `FLAG_NOT_FOUND`, the default value is returned with an error. + +```ruby +meta_provider = OpenFeature::MetaProvider.new( + providers: [provider_a, provider_b], + strategy: :first_successful +) +``` + +### `:comparison` + +Evaluates **all** providers (no short-circuit) and compares their results: + +- If all successful results agree, returns the unanimous value with `"comparison_result" => "unanimous"` in `flag_metadata`. +- If results disagree, returns the default value with a mismatch error listing each provider's value. +- Providers that error or raise are excluded from comparison (not fatal unless all fail). + +```ruby +meta_provider = OpenFeature::MetaProvider.new( + providers: [provider_a, provider_b], + strategy: :comparison +) +``` + +### Custom Strategies + +You can implement a custom strategy by subclassing `OpenFeature::MetaProvider::Strategy::Base`: + +```ruby +class MyCustomStrategy < OpenFeature::MetaProvider::Strategy::Base + def resolve(providers:, default_value:, &fetch_block) + # Your custom logic here + # Use add_provider_metadata(details, provider) to tag results + # Use default_error_result(default_value, error_message:) for errors + end +end + +meta_provider = OpenFeature::MetaProvider.new( + providers: [provider_a, provider_b], + strategy: MyCustomStrategy.new +) +``` + +## Tracking + +The MetaProvider delegates `track` calls to all wrapped providers that support it: + +```ruby +client.track("purchase_completed", evaluation_context: context) +``` + ## Development ### Running Tests diff --git a/providers/openfeature-meta_provider/lib/openfeature/meta_provider.rb b/providers/openfeature-meta_provider/lib/openfeature/meta_provider.rb index efe36c6..5e443d7 100644 --- a/providers/openfeature-meta_provider/lib/openfeature/meta_provider.rb +++ b/providers/openfeature-meta_provider/lib/openfeature/meta_provider.rb @@ -1,51 +1,59 @@ # frozen_string_literal: true +require_relative "meta_provider/strategy/base" +require_relative "meta_provider/strategy/first_match" +require_relative "meta_provider/strategy/first_successful" +require_relative "meta_provider/strategy/comparison" + module OpenFeature # Used to pull from multiple providers. class MetaProvider + STRATEGY_MAP = { + first_match: Strategy::FirstMatch.new, + first_successful: Strategy::FirstSuccessful.new, + comparison: Strategy::Comparison.new + }.freeze + + FETCH_TYPES = %w[boolean string number integer float object].freeze + # @param providers [Array] - # @param strategy [Symbol] When `:first_match`, returns first matched resolution. Providers will be searched - # in the order they were given. Defaults to `:first_match`. + # @param strategy [Symbol, Strategy::Base] Resolution strategy. Accepts a symbol (:first_match, + # :first_successful, :comparison) or a Strategy::Base subclass instance for custom strategies. + # Defaults to :first_match. def initialize(providers:, strategy: :first_match) @providers = providers - @strategy = strategy + @strategy = resolve_strategy(strategy) end def metadata - SDK::Provider::ProviderMetadata.new(name: "MetaProvider: #{providers.map do |provider| + @metadata ||= SDK::Provider::ProviderMetadata.new(name: "MetaProvider: #{providers.map do |provider| provider.metadata.name end.join(", ")}") end - def init - providers.each { |provider| provider.init if provider.respond_to?(:init) } + def init(evaluation_context = nil) + providers.each { |provider| provider.init(evaluation_context) if provider.respond_to?(:init) } end def shutdown - providers.each(&:shutdown) - end - - def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) - fetch_from_sources(default_value:) do |provider| - provider.fetch_boolean_value(flag_key:, default_value:, evaluation_context:) - end + providers.each { |provider| provider.shutdown if provider.respond_to?(:shutdown) } end - def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) - fetch_from_sources(default_value:) do |provider| - provider.fetch_number_value(flag_key:, default_value:, evaluation_context:) - end - end - - def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) - fetch_from_sources(default_value:) do |provider| - provider.fetch_object_value(flag_key:, default_value:, evaluation_context:) + FETCH_TYPES.each do |type| + define_method(:"fetch_#{type}_value") do |flag_key:, default_value:, evaluation_context: nil| + strategy.resolve(providers: providers, default_value: default_value) do |provider| + provider.send(:"fetch_#{type}_value", flag_key: flag_key, default_value: default_value, + evaluation_context: evaluation_context) + end end end - def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) - fetch_from_sources(default_value:) do |provider| - provider.fetch_string_value(flag_key:, default_value:, evaluation_context:) + def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil) + providers.each do |provider| + if provider.respond_to?(:track) + provider.track(tracking_event_name, evaluation_context: evaluation_context, + tracking_event_details: tracking_event_details) + end end end @@ -53,35 +61,13 @@ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) attr_reader :providers, :strategy - def fetch_from_sources(default_value:) - case strategy - when :first_match - successful_details = providers.each do |provider| - details = yield(provider) - - details = SDK::Provider::ResolutionDetails.new( - value: details.value, - reason: details.reason, - variant: details.variant, - error_code: details.error_code, - error_message: details.error_message, - flag_metadata: (details.flag_metadata || {}).merge("matched_provider" => provider.metadata.name) - ) + def resolve_strategy(strategy) + return strategy if strategy.is_a?(Strategy::Base) - break details if details.error_code.nil? - rescue - next - end - - if successful_details.is_a?(SDK::Provider::ResolutionDetails) - successful_details - else - SDK::Provider::ResolutionDetails.new(value: default_value, error_code: SDK::Provider::ErrorCode::GENERAL, - reason: SDK::Provider::Reason::ERROR) - end - else - SDK::Provider::ResolutionDetails.new(value: default_value, error_code: SDK::Provider::ErrorCode::GENERAL, - reason: "Unknown strategy for MetaProvider") + STRATEGY_MAP.fetch(strategy) do + raise ArgumentError, "Unknown strategy: #{strategy.inspect}. " \ + "Valid symbols: #{STRATEGY_MAP.keys.map(&:inspect).join(", ")}. " \ + "Or pass a Strategy::Base subclass instance." end end end diff --git a/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/base.rb b/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/base.rb new file mode 100644 index 0000000..43995b3 --- /dev/null +++ b/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/base.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module OpenFeature + class MetaProvider + module Strategy + class Base + MATCHED_PROVIDER_KEY = "matched_provider" + + def resolve(providers:, default_value:, &fetch_block) + raise NotImplementedError, "#{self.class}#resolve must be implemented" + end + + private + + def add_provider_metadata(details, provider) + with_merged_metadata(details, MATCHED_PROVIDER_KEY => provider.metadata.name) + end + + def with_merged_metadata(details, extra_metadata) + SDK::Provider::ResolutionDetails.new( + value: details.value, + reason: details.reason, + variant: details.variant, + error_code: details.error_code, + error_message: details.error_message, + flag_metadata: (details.flag_metadata || {}).merge(extra_metadata) + ) + end + + def default_error_result(default_value, error_message: nil) + SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: SDK::Provider::ErrorCode::GENERAL, + reason: SDK::Provider::Reason::ERROR, + error_message: error_message + ) + end + end + end + end +end diff --git a/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/comparison.rb b/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/comparison.rb new file mode 100644 index 0000000..6ef445c --- /dev/null +++ b/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/comparison.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module OpenFeature + class MetaProvider + module Strategy + class Comparison < Base + def resolve(providers:, default_value:, &fetch_block) + results = [] + errors = [] + + providers.each do |provider| + details = fetch_block.call(provider) + + if details.error_code.nil? + results << add_provider_metadata(details, provider) + else + errors << {provider: provider.metadata.name, error_code: details.error_code} + end + rescue => e + errors << {provider: provider.metadata.name, error_code: e.message} + end + + return default_error_result(default_value, error_message: "All providers failed") if results.empty? + + if results.all? { |r| r.value == results.first.value } + with_merged_metadata(results.first, "comparison_result" => "unanimous") + else + mismatch_details = results.map { |r| "#{r.flag_metadata[MATCHED_PROVIDER_KEY]}=#{r.value.inspect}" }.join(", ") + default_error_result( + default_value, + error_message: "Providers disagree: #{mismatch_details}" + ) + end + end + end + end + end +end diff --git a/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/first_match.rb b/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/first_match.rb new file mode 100644 index 0000000..5c0d70d --- /dev/null +++ b/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/first_match.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module OpenFeature + class MetaProvider + module Strategy + class FirstMatch < Base + def resolve(providers:, default_value:, &fetch_block) + successful_details = providers.each do |provider| + details = add_provider_metadata(fetch_block.call(provider), provider) + break details if details.error_code.nil? + rescue + next + end + + if successful_details.is_a?(SDK::Provider::ResolutionDetails) + successful_details + else + default_error_result(default_value) + end + end + end + end + end +end diff --git a/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/first_successful.rb b/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/first_successful.rb new file mode 100644 index 0000000..e8a5218 --- /dev/null +++ b/providers/openfeature-meta_provider/lib/openfeature/meta_provider/strategy/first_successful.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module OpenFeature + class MetaProvider + module Strategy + class FirstSuccessful < Base + def resolve(providers:, default_value:, &fetch_block) + providers.each do |provider| + details = add_provider_metadata(fetch_block.call(provider), provider) + + return details if details.error_code.nil? + next if details.error_code == SDK::Provider::ErrorCode::FLAG_NOT_FOUND + return details + rescue => e + return default_error_result( + default_value, + error_message: "Provider #{provider.metadata.name} raised: #{e.message}" + ) + end + + default_error_result(default_value, error_message: "No provider found a value for the flag") + end + end + end + end +end diff --git a/providers/openfeature-meta_provider/lib/openfeature/meta_provider_version.rb b/providers/openfeature-meta_provider/lib/openfeature/meta_provider_version.rb index b7177d5..5998b03 100644 --- a/providers/openfeature-meta_provider/lib/openfeature/meta_provider_version.rb +++ b/providers/openfeature-meta_provider/lib/openfeature/meta_provider_version.rb @@ -2,6 +2,6 @@ module OpenFeature class MetaProvider - VERSION = "0.0.7" + VERSION = "0.1.0" end end diff --git a/providers/openfeature-meta_provider/openfeature-meta_provider.gemspec b/providers/openfeature-meta_provider/openfeature-meta_provider.gemspec index 0d58357..c59bfa9 100644 --- a/providers/openfeature-meta_provider/openfeature-meta_provider.gemspec +++ b/providers/openfeature-meta_provider/openfeature-meta_provider.gemspec @@ -30,7 +30,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "openfeature-sdk", ">= 0.3.0", "<= 0.4" + spec.add_dependency "openfeature-sdk", ">= 0.4.0", "< 1.0" spec.add_development_dependency "debug", "~> 1.9.2" spec.add_development_dependency "rake", "~> 13.0" diff --git a/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/base_spec.rb b/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/base_spec.rb new file mode 100644 index 0000000..a03460a --- /dev/null +++ b/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/base_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe OpenFeature::MetaProvider::Strategy::Base do + describe "#resolve" do + it "raises NotImplementedError" do + base = described_class.new + expect { + base.resolve(providers: [], default_value: false) { |_| } + }.to raise_error(NotImplementedError, /must be implemented/) + end + end + + describe "custom subclass" do + let(:custom_strategy_class) do + Class.new(described_class) do + def resolve(providers:, default_value:, &fetch_block) + details = fetch_block.call(providers.last) + add_provider_metadata(details, providers.last) + end + end + end + + it "works when subclassed with resolve implemented" do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new("my_flag" => true) + strategy = custom_strategy_class.new + + result = strategy.resolve(providers: [provider], default_value: false) do |p| + p.fetch_boolean_value(flag_key: "my_flag", default_value: false) + end + + expect(result.value).to eq(true) + expect(result.flag_metadata["matched_provider"]).to eq("In-memory Provider") + end + end +end diff --git a/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/comparison_spec.rb b/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/comparison_spec.rb new file mode 100644 index 0000000..e521aef --- /dev/null +++ b/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/comparison_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe OpenFeature::MetaProvider::Strategy::Comparison do + subject(:strategy) { described_class.new } + + let(:provider_one) do + OpenFeature::SDK::Provider::InMemoryProvider.new("flag_a" => "same_value") + end + + let(:provider_two) do + OpenFeature::SDK::Provider::InMemoryProvider.new("flag_a" => "same_value") + end + + describe "#resolve" do + it "returns unanimous result when all providers agree" do + result = strategy.resolve(providers: [provider_one, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_a", default_value: "default") + end + + expect(result.value).to eq("same_value") + expect(result.error_code).to be_nil + expect(result.flag_metadata["comparison_result"]).to eq("unanimous") + end + + it "returns mismatch error when providers disagree" do + disagreeing_provider = OpenFeature::SDK::Provider::InMemoryProvider.new("flag_a" => "different_value") + + result = strategy.resolve(providers: [provider_one, disagreeing_provider], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_a", default_value: "default") + end + + expect(result.value).to eq("default") + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + expect(result.error_message).to include("Providers disagree") + expect(result.error_message).to include("same_value") + expect(result.error_message).to include("different_value") + end + + it "excludes erroring providers from comparison" do + error_provider = instance_double( + OpenFeature::SDK::Provider::InMemoryProvider, + metadata: OpenFeature::SDK::Provider::ProviderMetadata.new(name: "ErrorProvider") + ) + allow(error_provider).to receive(:fetch_string_value).and_return( + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: "default", + error_code: OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + ) + + result = strategy.resolve(providers: [provider_one, error_provider, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_a", default_value: "default") + end + + expect(result.value).to eq("same_value") + expect(result.flag_metadata["comparison_result"]).to eq("unanimous") + end + + it "excludes raising providers from comparison" do + failing_provider = instance_double( + OpenFeature::SDK::Provider::InMemoryProvider, + metadata: OpenFeature::SDK::Provider::ProviderMetadata.new(name: "FailProvider") + ) + allow(failing_provider).to receive(:fetch_string_value).and_raise(RuntimeError, "boom") + + result = strategy.resolve(providers: [provider_one, failing_provider, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_a", default_value: "default") + end + + expect(result.value).to eq("same_value") + expect(result.flag_metadata["comparison_result"]).to eq("unanimous") + end + + it "returns default error when all providers fail" do + failing_provider = instance_double( + OpenFeature::SDK::Provider::InMemoryProvider, + metadata: OpenFeature::SDK::Provider::ProviderMetadata.new(name: "FailProvider") + ) + allow(failing_provider).to receive(:fetch_string_value).and_raise(RuntimeError, "boom") + + result = strategy.resolve(providers: [failing_provider], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_a", default_value: "default") + end + + expect(result.value).to eq("default") + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + expect(result.error_message).to eq("All providers failed") + end + end +end diff --git a/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/first_match_spec.rb b/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/first_match_spec.rb new file mode 100644 index 0000000..5c01af7 --- /dev/null +++ b/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/first_match_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe OpenFeature::MetaProvider::Strategy::FirstMatch do + subject(:strategy) { described_class.new } + + let(:provider_one) do + OpenFeature::SDK::Provider::InMemoryProvider.new("flag_a" => "from_one") + end + + let(:provider_two) do + OpenFeature::SDK::Provider::InMemoryProvider.new("flag_b" => "from_two") + end + + describe "#resolve" do + it "returns from the first provider that matches" do + result = strategy.resolve(providers: [provider_one, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_a", default_value: "default") + end + + expect(result.value).to eq("from_one") + expect(result.flag_metadata["matched_provider"]).to eq("In-memory Provider") + end + + it "returns from the second provider when first does not match" do + result = strategy.resolve(providers: [provider_one, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_b", default_value: "default") + end + + expect(result.value).to eq("from_two") + end + + it "returns default when no providers match" do + result = strategy.resolve(providers: [provider_one, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "missing", default_value: "default") + end + + expect(result.value).to eq("default") + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + end + + it "skips providers that raise and tries the next" do + result = strategy.resolve(providers: [provider_one, provider_two], default_value: "default") do |provider| + raise "boom" if provider == provider_one + provider.fetch_string_value(flag_key: "flag_b", default_value: "default") + end + + expect(result.value).to eq("from_two") + end + end +end diff --git a/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/first_successful_spec.rb b/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/first_successful_spec.rb new file mode 100644 index 0000000..e193838 --- /dev/null +++ b/providers/openfeature-meta_provider/spec/openfeature/meta_provider/strategy/first_successful_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe OpenFeature::MetaProvider::Strategy::FirstSuccessful do + subject(:strategy) { described_class.new } + + let(:provider_one) do + OpenFeature::SDK::Provider::InMemoryProvider.new("flag_a" => "from_one") + end + + let(:provider_two) do + OpenFeature::SDK::Provider::InMemoryProvider.new("flag_b" => "from_two") + end + + describe "#resolve" do + it "returns from the first provider that succeeds" do + result = strategy.resolve(providers: [provider_one, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_a", default_value: "default") + end + + expect(result.value).to eq("from_one") + expect(result.flag_metadata["matched_provider"]).to eq("In-memory Provider") + end + + it "skips FLAG_NOT_FOUND and tries the next provider" do + result = strategy.resolve(providers: [provider_one, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_b", default_value: "default") + end + + expect(result.value).to eq("from_two") + end + + it "stops on non-FLAG_NOT_FOUND errors" do + error_provider = instance_double( + OpenFeature::SDK::Provider::InMemoryProvider, + metadata: OpenFeature::SDK::Provider::ProviderMetadata.new(name: "ErrorProvider") + ) + allow(error_provider).to receive(:fetch_string_value).and_return( + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: "default", + error_code: OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR, + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + ) + + result = strategy.resolve(providers: [error_provider, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_b", default_value: "default") + end + + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) + expect(result.flag_metadata["matched_provider"]).to eq("ErrorProvider") + end + + it "stops on exceptions and surfaces the error" do + failing_provider = instance_double( + OpenFeature::SDK::Provider::InMemoryProvider, + metadata: OpenFeature::SDK::Provider::ProviderMetadata.new(name: "FailProvider") + ) + allow(failing_provider).to receive(:fetch_string_value).and_raise(RuntimeError, "connection lost") + + result = strategy.resolve(providers: [failing_provider, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "flag_b", default_value: "default") + end + + expect(result.value).to eq("default") + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + expect(result.error_message).to include("FailProvider") + expect(result.error_message).to include("connection lost") + end + + it "returns default error when all providers return FLAG_NOT_FOUND" do + result = strategy.resolve(providers: [provider_one, provider_two], default_value: "default") do |provider| + provider.fetch_string_value(flag_key: "missing", default_value: "default") + end + + expect(result.value).to eq("default") + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + expect(result.error_message).to eq("No provider found a value for the flag") + end + end +end diff --git a/providers/openfeature-meta_provider/spec/openfeature/meta_provider_spec.rb b/providers/openfeature-meta_provider/spec/openfeature/meta_provider_spec.rb index 9e388db..41c54a4 100644 --- a/providers/openfeature-meta_provider/spec/openfeature/meta_provider_spec.rb +++ b/providers/openfeature-meta_provider/spec/openfeature/meta_provider_spec.rb @@ -67,14 +67,14 @@ subject(:meta_provider) { described_class.new(providers: [provider_one, provider_two]) } let(:provider) { meta_provider } - it_behaves_like "an OpenFeature provider" - let(:provider_one) do OpenFeature::SDK::Provider::InMemoryProvider.new( { "first_match_boolean" => true, "first_match_string" => "first", "first_match_number" => 1, + "first_match_integer" => 10, + "first_match_float" => 1.5, "first_match_object" => {one: 1} } ) @@ -85,6 +85,8 @@ "second_match_boolean" => false, "second_match_string" => "second", "second_match_number" => 2, + "second_match_integer" => 20, + "second_match_float" => 2.5, "second_match_object" => {two: 2} } ) @@ -94,6 +96,7 @@ let(:provider) { meta_provider } it_behaves_like "an OpenFeature provider" + it_behaves_like "an OpenFeature provider with integer and float support" end describe "#metadata" do @@ -109,6 +112,10 @@ meta_provider.init end + + it "accepts an optional evaluation_context" do + expect { meta_provider.init(nil) }.not_to raise_error + end end describe "#shutdown" do @@ -118,6 +125,16 @@ meta_provider.shutdown end + + it "guards against providers without shutdown" do + no_shutdown_provider = Object.new + def no_shutdown_provider.metadata + OpenFeature::SDK::Provider::ProviderMetadata.new(name: "NoShutdown") + end + + mp = described_class.new(providers: [no_shutdown_provider]) + expect { mp.shutdown }.not_to raise_error + end end describe "#fetch_boolean_value" do @@ -132,7 +149,72 @@ include_examples "meta resolution", "number", 3, 1, 2 end + describe "#fetch_integer_value" do + include_examples "meta resolution", "integer", 0, 10, 20 + end + + describe "#fetch_float_value" do + include_examples "meta resolution", "float", 0.0, 1.5, 2.5 + end + describe "#fetch_object_value" do include_examples "meta resolution", "object", {}, {one: 1}, {two: 2} end + + describe "#track" do + it "delegates to all providers that respond to track" do + trackable_class = Class.new do + attr_reader :metadata, :tracked_calls + + def initialize + @metadata = OpenFeature::SDK::Provider::ProviderMetadata.new(name: "Trackable") + @tracked_calls = [] + end + + def track(event_name, evaluation_context: nil, tracking_event_details: nil) + @tracked_calls << {event_name: event_name, evaluation_context: evaluation_context, + tracking_event_details: tracking_event_details} + end + + def init = nil + + def shutdown = nil + end + + trackable = trackable_class.new + + mp = described_class.new(providers: [trackable, provider_one]) + mp.track("event_name", evaluation_context: nil, tracking_event_details: nil) + + expect(trackable.tracked_calls).to eq([{ + event_name: "event_name", + evaluation_context: nil, + tracking_event_details: nil + }]) + end + end + + describe "strategy acceptance" do + it "accepts :first_match symbol" do + expect { described_class.new(providers: [provider_one], strategy: :first_match) }.not_to raise_error + end + + it "accepts :first_successful symbol" do + expect { described_class.new(providers: [provider_one], strategy: :first_successful) }.not_to raise_error + end + + it "accepts :comparison symbol" do + expect { described_class.new(providers: [provider_one], strategy: :comparison) }.not_to raise_error + end + + it "accepts a Strategy::Base subclass instance" do + custom = OpenFeature::MetaProvider::Strategy::FirstMatch.new + expect { described_class.new(providers: [provider_one], strategy: custom) }.not_to raise_error + end + + it "raises ArgumentError for invalid strategy symbol" do + expect { described_class.new(providers: [provider_one], strategy: :invalid) } + .to raise_error(ArgumentError, /Unknown strategy/) + end + end end