diff --git a/Gemfile.lock b/Gemfile.lock index ef4da6e..df5efd1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -30,6 +31,7 @@ PLATFORMS DEPENDENCIES featurevisor! + openfeature-sdk (~> 0.6.5) rake (~> 13.0) rspec (~> 3.12) diff --git a/README.md b/README.md index 0faed19..75237ac 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. + ## Development diff --git a/featurevisor.gemspec b/featurevisor.gemspec index 7a42ea8..5a2ff14 100644 --- a/featurevisor.gemspec +++ b/featurevisor.gemspec @@ -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 diff --git a/lib/featurevisor/openfeature_provider.rb b/lib/featurevisor/openfeature_provider.rb new file mode 100644 index 0000000..ab04195 --- /dev/null +++ b/lib/featurevisor/openfeature_provider.rb @@ -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 diff --git a/spec/openfeature_provider_spec.rb b/spec/openfeature_provider_spec.rb new file mode 100644 index 0000000..baad944 --- /dev/null +++ b/spec/openfeature_provider_spec.rb @@ -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