Skip to content
Open
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
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ GEM
specs:
benchmark (0.4.0)
diff-lcs (1.6.2)
openfeature-sdk (0.6.5)
rake (13.3.0)
rspec (3.13.1)
rspec-core (~> 3.13.0)
Expand All @@ -30,6 +31,7 @@ PLATFORMS

DEPENDENCIES
featurevisor!
openfeature-sdk (~> 0.6.5)
rake (~> 13.0)
rspec (~> 3.12)

Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles.
- [Registering modules](#registering-modules)
- [Child instance](#child-instance)
- [Close](#close)
- [OpenFeature](#openfeature)
- [CLI usage](#cli-usage)
- [Test](#test)
- [Test against local monorepo's example-1](#test-against-local-monorepos-example-1)
Expand Down Expand Up @@ -811,6 +812,49 @@ $ bundle exec featurevisor assess-distribution \
--n=1000
```

## OpenFeature

Install the official OpenFeature SDK next to Featurevisor:

```ruby
gem "featurevisor"
gem "openfeature-sdk", "~> 0.6.5"
```

```ruby
require "featurevisor/openfeature_provider"

provider = Featurevisor::OpenFeatureProvider.new(
datafile: datafile_content,
)

OpenFeature::SDK.configure do |config|
config.set_provider_and_wait(provider)
end

client = OpenFeature::SDK.build_client
enabled = client.fetch_boolean_value(
flag_key: "checkout",
default_value: false,
evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123"),
)
```

Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Arrays, hashes, and JSON variables use the object resolver.

OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping.

You can also reuse an existing Featurevisor instance:

```ruby
featurevisor = Featurevisor.create_featurevisor(datafile: datafile_content)
provider = Featurevisor::OpenFeatureProvider.new(featurevisor: featurevisor)
```

The caller owns an instance passed this way. Provider shutdown does not close it. Call `featurevisor.close` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `featurevisor` takes precedence over the options hash.

See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages.

<!-- FEATUREVISOR_DOCS_END -->

## Development
Expand Down
1 change: 1 addition & 0 deletions featurevisor.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ Gem::Specification.new do |spec|
spec.add_dependency "benchmark", ">= 0"
spec.add_development_dependency "rspec", "~> 3.12"
spec.add_development_dependency "rake", "~> 13.0"
spec.add_development_dependency "openfeature-sdk", "~> 0.6.5"
end
187 changes: 187 additions & 0 deletions lib/featurevisor/openfeature_provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# frozen_string_literal: true

require "json"
require "time"
require "open_feature/sdk"
require_relative "../featurevisor"

module Featurevisor
class OpenFeatureProvider
Provider = OpenFeature::SDK::Provider

attr_reader :metadata, :featurevisor

def initialize(options = {}, featurevisor: nil, targeting_key_field: "userId", key_separator: ":", variation_key: "variation", on_track: nil)
@metadata = Provider::ProviderMetadata.new(name: "Featurevisor").freeze
@targeting_key_field = targeting_key_field.empty? ? "userId" : targeting_key_field
@key_separator = key_separator.empty? ? ":" : key_separator
@variation_key = variation_key.empty? ? "variation" : variation_key
@on_track = on_track
@datafile_error = nil
@owns_featurevisor = featurevisor.nil?
if featurevisor
@featurevisor = featurevisor
else
resolved_options = options.dup
original_handler = resolved_options[:onDiagnostic] || resolved_options[:on_diagnostic]
resolved_options[:onDiagnostic] = lambda do |diagnostic|
@datafile_error = diagnostic[:message] if diagnostic[:code] == "invalid_datafile"
@datafile_error = nil if diagnostic[:code] == "datafile_set"
original_handler&.call(diagnostic)
end
@featurevisor = Featurevisor.create_featurevisor(resolved_options)
end
@datafile_unsubscribe = @featurevisor.on("datafile_set", ->(*) { @datafile_error = nil })
end

def init(_evaluation_context = nil); end
def shutdown
@datafile_unsubscribe&.call
featurevisor.close if @owns_featurevisor
end
def hooks = []

def track(tracking_event_name:, evaluation_context: nil, tracking_event_details: nil)
@on_track&.call(tracking_event_name, evaluation_context, tracking_event_details)
end

def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
resolve(flag_key, default_value, evaluation_context, :boolean)
end

def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
resolve(flag_key, default_value, evaluation_context, :string)
end

def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
resolve(flag_key, default_value, evaluation_context, :number)
end

alias fetch_integer_value fetch_number_value
alias fetch_float_value fetch_number_value

def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
resolve(flag_key, default_value, evaluation_context, :object)
end

private

def resolve(flag_key, default_value, evaluation_context, expected_type)
return error(default_value, Provider::ErrorCode::PARSE_ERROR, @datafile_error) if @datafile_error

feature_key, selector = split_key(flag_key)
context = normalize(evaluation_context&.fields || {})
targeting_key = evaluation_context&.targeting_key
context[@targeting_key_field] = targeting_key if targeting_key && !targeting_key.empty?

if selector.nil? || selector.empty?
return type_mismatch(flag_key, default_value, expected_type) unless expected_type == :boolean
evaluation = featurevisor.evaluate_flag(feature_key, context)
value = evaluation[:enabled]
elsif selector == @variation_key
evaluation = featurevisor.evaluate_variation(feature_key, context)
value = evaluation[:variation_value] || evaluation.dig(:variation, :value)
else
evaluation = featurevisor.evaluate_variable(feature_key, selector, context)
value = evaluation[:variable_value]
if evaluation.dig(:variable_schema, :type) == "json" && value.is_a?(String)
begin
value = JSON.parse(value)
rescue JSON::ParserError
# Type validation below returns TYPE_MISMATCH when object was requested.
end
end
end

metadata = metadata_for(evaluation)
code = error_code(evaluation[:reason])
return error(default_value, code, error_message(evaluation), metadata) if code
value = default_value if value.nil?
value = normalize(value) if expected_type == :object
return type_mismatch(flag_key, default_value, expected_type, metadata) unless matches?(value, expected_type)

Provider::ResolutionDetails.new(
value: value,
reason: reason(evaluation[:reason]),
variant: variant(evaluation),
flag_metadata: metadata
)
end

def split_key(key)
index = key.index(@key_separator)
index ? [key[0...index], key[(index + @key_separator.length)..]] : [key, nil]
end

def metadata_for(evaluation)
metadata = {
"featureKey" => evaluation[:feature_key],
"featurevisorReason" => evaluation[:reason],
"schemaVersion" => featurevisor.get_schema_version
}
metadata["revision"] = featurevisor.get_revision if featurevisor.get_revision
{
variable_key: "variableKey",
rule_key: "ruleKey",
bucket_key: "bucketKey",
bucket_value: "bucketValue",
force_index: "forceIndex",
variable_override_index: "variableOverrideIndex"
}.each do |key, metadata_key|
metadata[metadata_key] = evaluation[key] unless evaluation[key].nil?
end
metadata
end

def reason(value)
return Provider::Reason::ERROR if %w[feature_not_found variable_not_found no_variations error].include?(value)
return Provider::Reason::TARGETING_MATCH if %w[required forced sticky rule variable_override_variation variable_override_rule].include?(value)
return Provider::Reason::SPLIT if value == "allocated"
return Provider::Reason::DISABLED if %w[disabled variation_disabled variable_disabled].include?(value)
Provider::Reason::DEFAULT
end

def error_code(value)
return Provider::ErrorCode::FLAG_NOT_FOUND if %w[feature_not_found variable_not_found no_variations].include?(value)
return Provider::ErrorCode::GENERAL if value == "error"
nil
end

def error_message(evaluation)
return evaluation[:error].message if evaluation[:error].respond_to?(:message)
return %(Feature "#{evaluation[:feature_key]}" was not found) if evaluation[:reason] == "feature_not_found"
return %(Variable "#{evaluation[:variable_key]}" was not found for feature "#{evaluation[:feature_key]}") if evaluation[:reason] == "variable_not_found"
return %(Feature "#{evaluation[:feature_key]}" has no variations) if evaluation[:reason] == "no_variations"
"Featurevisor evaluation failed"
end

def variant(evaluation) = evaluation[:variation_value] || evaluation.dig(:variation, :value)

def matches?(value, type)
case type
when :boolean then value == true || value == false
when :string then value.is_a?(String)
when :number then value.is_a?(Numeric) && value.finite?
when :object then value.is_a?(Hash) || value.is_a?(Array)
else false
end
end

def normalize(value)
case value
when Time, DateTime then value.iso8601
when Hash then value.to_h { |key, item| [key.to_s, normalize(item)] }
when Array then value.map { |item| normalize(item) }
else value
end
end

def error(value, code, message, metadata = {})
Provider::ResolutionDetails.new(value: value, reason: Provider::Reason::ERROR, error_code: code, error_message: message, flag_metadata: metadata)
end

def type_mismatch(key, value, expected_type, metadata = {})
error(value, Provider::ErrorCode::TYPE_MISMATCH, %(Flag "#{key}" did not resolve to a #{expected_type} value), metadata)
end
end
end
89 changes: 89 additions & 0 deletions spec/openfeature_provider_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
require "featurevisor/openfeature_provider"

RSpec.describe Featurevisor::OpenFeatureProvider do
let(:datafile) do
{
schemaVersion: "2", revision: "openfeature-test", segments: {},
features: {
"checkout" => {
bucketBy: "userId",
variations: [{ value: "on", variables: { title: "Hello", count: 3, ratio: 1.5, visible: true, items: ["a"], config: { color: "blue" }, json: '{"nested":true}' } }],
variablesSchema: {
title: { type: "string", defaultValue: "Default" }, count: { type: "integer", defaultValue: 0 },
ratio: { type: "double", defaultValue: 0 }, visible: { type: "boolean", defaultValue: false },
items: { type: "array", defaultValue: [] }, config: { type: "object", defaultValue: {} }, json: { type: "json", defaultValue: "{}" }
},
force: [{ conditions: { attribute: "userId", operator: "equals", value: "forced-user" }, enabled: true, variation: "on" }],
traffic: [{ key: "all", segments: "*", percentage: 100_000, variation: "on" }]
}
}
}
end

def provider(**options)
described_class.new({ datafile: datafile, logLevel: "fatal" }, **options)
end

it "resolves every OpenFeature type and maps targeting key" do
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "forced-user")
expect(provider.fetch_boolean_value(flag_key: "checkout", default_value: false, evaluation_context: context).value).to be(true)
expect(provider.fetch_string_value(flag_key: "checkout:variation", default_value: "fallback", evaluation_context: context).value).to eq("on")
expect(provider.fetch_string_value(flag_key: "checkout:title", default_value: "fallback", evaluation_context: context).value).to eq("Hello")
expect(provider.fetch_integer_value(flag_key: "checkout:count", default_value: 0, evaluation_context: context).value).to eq(3)
expect(provider.fetch_float_value(flag_key: "checkout:ratio", default_value: 0.0, evaluation_context: context).value).to eq(1.5)
expect(provider.fetch_boolean_value(flag_key: "checkout:visible", default_value: false, evaluation_context: context).value).to be(true)
expect(provider.fetch_object_value(flag_key: "checkout:items", default_value: [], evaluation_context: context).value).to eq(["a"])
expect(provider.fetch_object_value(flag_key: "checkout:config", default_value: {}, evaluation_context: context).value).to eq({ "color" => "blue" })
expect(provider.fetch_object_value(flag_key: "checkout:json", default_value: {}, evaluation_context: context).value).to eq({ "nested" => true })
end

it "supports errors, custom grammar, tracking, and shutdown" do
tracked = []
instance = provider(key_separator: "/", variation_key: "$variation", on_track: ->(*args) { tracked << args })
expect(instance.fetch_string_value(flag_key: "checkout/$variation", default_value: "fallback").value).to eq("on")
expect(instance.fetch_string_value(flag_key: "missing", default_value: "fallback").error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH)
missing = instance.fetch_boolean_value(flag_key: "missing", default_value: true)
expect(missing.value).to be(true)
expect(missing.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND)
instance.track(tracking_event_name: "purchase")
expect(tracked.first.first).to eq("purchase")
instance.shutdown
end

it "works through the OpenFeature SDK" do
OpenFeature::SDK.configure { |configuration| configuration.set_provider_and_wait(provider) }
client = OpenFeature::SDK.build_client
value = client.fetch_boolean_value(
flag_key: "checkout",
default_value: false,
evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "forced-user")
)
expect(value).to be(true)
end

it "returns a parse error for malformed datafiles" do
instance = described_class.new({ datafile: "{", logLevel: "fatal" })
result = instance.fetch_boolean_value(flag_key: "checkout", default_value: false)
expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR)
expect(result.error_message).to eq("Could not parse datafile")
instance.featurevisor.set_datafile(datafile, true)
expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).value).to be(true)
end

it "borrows an existing Featurevisor instance" do
closed = false
featurevisor = Featurevisor.create_featurevisor(
datafile: datafile,
logLevel: "fatal",
modules: [{ name: "owner", close: -> { closed = true } }]
)
instance = described_class.new(featurevisor: featurevisor)

expect(instance.featurevisor).to equal(featurevisor)
instance.shutdown
expect(closed).to be(false)

featurevisor.close
expect(closed).to be(true)
end
end
Loading