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
6 changes: 3 additions & 3 deletions providers/openfeature-meta_provider/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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/
Expand All @@ -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)
Expand Down
66 changes: 65 additions & 1 deletion providers/openfeature-meta_provider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,87 +1,73 @@
# 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<Provider>]
# @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

private

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +8 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The errors array is initialized and populated, but its value is never used. This adds unnecessary complexity and can be confusing for future maintainers. The logic can be simplified by removing the errors array and only collecting successful results, while continuing to ignore providers that return an error or raise an exception.

          results = []

          providers.each do |provider|
            begin
              details = fetch_block.call(provider)
              results << add_provider_metadata(details, provider) if details.error_code.nil?
            rescue StandardError
              # Ignore providers that error or raise; they are excluded from comparison.
            end
          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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module OpenFeature
class MetaProvider
VERSION = "0.0.7"
VERSION = "0.1.0"
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading