From 7550e14d94f6a70b0087ba9801dfbfe93c1b374d Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 16 Jul 2024 12:35:07 -0400 Subject: [PATCH] feat: Add initial implementation of provider (#1) --- .gitignore | 2 + .rspec | 1 - lib/ldclient-openfeature.rb | 16 +- .../impl/context_converter.rb | 147 +++++++++++++ .../impl/details_converter.rb | 86 ++++++++ lib/ldclient-openfeature/provider.rb | 113 ++++++++++ spec/impl/context_converter_spec.rb | 201 ++++++++++++++++++ spec/impl/details_converter_spec.rb | 37 ++++ spec/provider_spec.rb | 91 ++++++++ 9 files changed, 681 insertions(+), 13 deletions(-) create mode 100644 lib/ldclient-openfeature/impl/context_converter.rb create mode 100644 lib/ldclient-openfeature/impl/details_converter.rb create mode 100644 lib/ldclient-openfeature/provider.rb create mode 100644 spec/impl/context_converter_spec.rb create mode 100644 spec/impl/details_converter_spec.rb create mode 100644 spec/provider_spec.rb diff --git a/.gitignore b/.gitignore index b04a8c8..5c249f1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ # rspec failure tracking .rspec_status + +Gemfile.lock diff --git a/.rspec b/.rspec index 34c5164..83e16f8 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,2 @@ ---format documentation --color --require spec_helper diff --git a/lib/ldclient-openfeature.rb b/lib/ldclient-openfeature.rb index 6f979a1..a202cc1 100644 --- a/lib/ldclient-openfeature.rb +++ b/lib/ldclient-openfeature.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true +require_relative "ldclient-openfeature/impl/context_converter" +require_relative "ldclient-openfeature/impl/details_converter" +require_relative "ldclient-openfeature/provider" require_relative "ldclient-openfeature/version" + require "logger" module LaunchDarkly @@ -8,17 +12,5 @@ module LaunchDarkly # Namespace for the LaunchDarkly OpenFeature provider. # module OpenFeature - # - # @return [Logger] the Rails logger if in Rails, or a default Logger at WARN level otherwise - # - def self.default_logger - if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger - Rails.logger - else - log = ::Logger.new($stdout) - log.level = ::Logger::WARN - log - end - end end end diff --git a/lib/ldclient-openfeature/impl/context_converter.rb b/lib/ldclient-openfeature/impl/context_converter.rb new file mode 100644 index 0000000..e6621b9 --- /dev/null +++ b/lib/ldclient-openfeature/impl/context_converter.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'ldclient-rb' +require 'open_feature/sdk' + +module LaunchDarkly + module OpenFeature + module Impl + class EvaluationContextConverter + # + # @param logger [Logger] + # + def initialize(logger) + @logger = logger + end + + # + # Create an LDContext from an EvaluationContext. + # + # A context will always be created, but the created context may be invalid. Log messages will be written to + # indicate the source of the problem. + # + # @param context [OpenFeature::SDK::EvaluationContext] + # + # @return [LaunchDarkly::LDContext] + # + def to_ld_context(context) + kind = context.field('kind') + + return build_multi_context(context) if kind == "multi" + + unless kind.nil? || kind.is_a?(String) + @logger.warn("'kind' was set to a non-string value; defaulting to user") + kind = 'user' + end + + targeting_key = context.targeting_key + key = context.field('key') + targeting_key = get_targeting_key(targeting_key, key) + + kind ||= 'user' + build_single_context(context.fields, kind, targeting_key) + end + + # + # @param targeting_key [String, nil] + # @param key [any] + # + # @return [String] + # + private def get_targeting_key(targeting_key, key) + # The targeting key may be set but empty. So we want to treat an empty string as a not defined one. Later it + # could become null, so we will need to check that. + if !targeting_key.nil? && targeting_key != "" && key.is_a?(String) + # There is both a targeting key and a key. It will work, but probably is not intentional. + @logger.warn("EvaluationContext contained both a 'key' and 'targeting_key'.") + end + + @logger.warn("A non-string 'key' attribute was provided.") unless key.nil? || key.is_a?(String) + + targeting_key ||= key unless key.nil? || !key.is_a?(String) + + if targeting_key.nil? || targeting_key == "" || !targeting_key.is_a?(String) + @logger.error("The EvaluationContext must contain either a 'targeting_key' or a 'key' and the type must be a string.") + end + + targeting_key || "" + end + + # + # @param context [OpenFeature::SDK::EvaluationContext] + # + # @return [LaunchDarkly::LDContext] + # + private def build_multi_context(context) + contexts = [] + + context.fields.each do |kind, attributes| + next if kind == 'kind' + + unless attributes.is_a?(Hash) + @logger.warn("Top level attributes in a multi-kind context should be dictionaries") + next + end + + key = attributes.fetch(:key, nil) + targeting_key = attributes.fetch(:targeting_key, nil) + + next unless targeting_key.nil? || targeting_key.is_a?(String) + + targeting_key = get_targeting_key(targeting_key, key) + single_context = build_single_context(attributes, kind, targeting_key) + + contexts << single_context + end + + LaunchDarkly::LDContext.create_multi(contexts) + end + + # + # @param attributes [Hash] + # @param kind [String] + # @param key [String] + # + # @return [LaunchDarkly::LDContext] + # + private def build_single_context(attributes, kind, key) + context = { kind: kind, key: key } + + attributes.each do |k, v| + next if %w[key targeting_key kind].include? k + + if k == 'name' && v.is_a?(String) + context[:name] = v + elsif k == 'name' + @logger.error("The attribute 'name' must be a string") + next + elsif k == 'anonymous' && [true, false].include?(v) + context[:anonymous] = v + elsif k == 'anonymous' + @logger.error("The attribute 'anonymous' must be a boolean") + next + elsif k == 'privateAttributes' && v.is_a?(Array) + private_attributes = [] + v.each do |private_attribute| + unless private_attribute.is_a?(String) + @logger.error("'privateAttributes' must be an array of only string values") + next + end + + private_attributes << private_attribute + end + + context[:_meta] = { privateAttributes: private_attributes } unless private_attributes.empty? + elsif k == 'privateAttributes' + @logger.error("The attribute 'privateAttributes' must be an array") + else + context[k.to_sym] = v + end + end + + LaunchDarkly::LDContext.create(context) + end + end + end + end +end diff --git a/lib/ldclient-openfeature/impl/details_converter.rb b/lib/ldclient-openfeature/impl/details_converter.rb new file mode 100644 index 0000000..55b04b9 --- /dev/null +++ b/lib/ldclient-openfeature/impl/details_converter.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'ldclient-rb' +require 'open_feature/sdk' + +module LaunchDarkly + module OpenFeature + module Impl + class ResolutionDetailsConverter + # + # @param detail [LaunchDarkly::EvaluationDetail] + # + # @return [OpenFeature::SDK::ResolutionDetails] + # + def to_resolution_details(detail) + value = detail.value + is_default = detail.variation_index.nil? + variation_index = detail.variation_index + + reason = detail.reason + reason_kind = reason.kind + + openfeature_reason = kind_to_reason(reason_kind) + + openfeature_error_code = nil + if reason_kind == LaunchDarkly::EvaluationReason::ERROR + openfeature_error_code = error_kind_to_code(reason.error_kind) + end + + openfeature_variant = nil + openfeature_variant = variation_index.to_s unless is_default + + ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: value, + error_code: openfeature_error_code, + error_message: nil, + reason: openfeature_reason, + variant: openfeature_variant + ) + end + + # + # @param kind [Symbol] + # + # @return [String] + # + private def kind_to_reason(kind) + case kind + when LaunchDarkly::EvaluationReason::OFF + ::OpenFeature::SDK::Provider::Reason::DISABLED + when LaunchDarkly::EvaluationReason::TARGET_MATCH + ::OpenFeature::SDK::Provider::Reason::TARGETING_MATCH + when LaunchDarkly::EvaluationReason::ERROR + ::OpenFeature::SDK::Provider::Reason::ERROR + else + # NOTE: FALLTHROUGH, RULE_MATCH, PREREQUISITE_FAILED intentionally + kind.to_s + end + end + + # + # @param error_kind [Symbol] + # + # @return [String] + # + private def error_kind_to_code(error_kind) + return ::OpenFeature::SDK::Provider::ErrorCode::GENERAL if error_kind.nil? + + case error_kind + when LaunchDarkly::EvaluationReason::ERROR_CLIENT_NOT_READY + ::OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY + when LaunchDarkly::EvaluationReason::ERROR_FLAG_NOT_FOUND + ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND + when LaunchDarkly::EvaluationReason::ERROR_MALFORMED_FLAG + ::OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR + when LaunchDarkly::EvaluationReason::ERROR_USER_NOT_SPECIFIED + ::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING + else + # NOTE: EXCEPTION_ERROR intentionally omitted + ::OpenFeature::SDK::Provider::ErrorCode::GENERAL + end + end + end + end + end +end diff --git a/lib/ldclient-openfeature/provider.rb b/lib/ldclient-openfeature/provider.rb new file mode 100644 index 0000000..f81ba0a --- /dev/null +++ b/lib/ldclient-openfeature/provider.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'ldclient-rb' +require 'open_feature/sdk' + +module LaunchDarkly + module OpenFeature + class Provider + attr_reader :metadata + + NUMERIC_TYPES = %i[integer float number].freeze + private_constant :NUMERIC_TYPES + + # + # @param sdk_key [String] + # @param config [LaunchDarkly::Config] + # @param wait_for_seconds [Float] + # + def initialize(sdk_key, config, wait_for_seconds = 5) + @client = LaunchDarkly::LDClient.new(sdk_key, config, wait_for_seconds) + + @context_converter = Impl::EvaluationContextConverter.new(config.logger) + @details_converter = Impl::ResolutionDetailsConverter.new + + @metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: "launchdarkly-openfeature-server").freeze + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + resolve_value(:boolean, flag_key, default_value, evaluation_context) + end + + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + resolve_value(:string, flag_key, default_value, evaluation_context) + end + + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + resolve_value(:number, flag_key, default_value, evaluation_context) + end + + def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) + resolve_value(:integer, flag_key, default_value, evaluation_context) + end + + def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) + resolve_value(:float, flag_key, default_value, evaluation_context) + end + + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) + resolve_value(:object, flag_key, default_value, evaluation_context) + end + + # + # @param flag_type [Symbol] + # @param flag_key [String] + # @param default_value [any] + # @param evaluation_context [::OpenFeature::SDK::EvaluationContext, nil] + # + # @return [::OpenFeature::SDK::Provider::ResolutionDetails] + # + private def resolve_value(flag_type, flag_key, default_value, evaluation_context) + if evaluation_context.nil? + return ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: ::OpenFeature::SDK::Provider::Reason::ERROR, + error_code: ::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING + ) + end + + ld_context = @context_converter.to_ld_context(evaluation_context) + evaluation_detail = @client.variation_detail(flag_key, ld_context, default_value) + + if flag_type == :boolean && ![true, false].include?(evaluation_detail.value) + return mismatched_type_details(default_value) + elsif flag_type == :string && !evaluation_detail.value.is_a?(String) + return mismatched_type_details(default_value) + elsif NUMERIC_TYPES.include?(flag_type) && !evaluation_detail.value.is_a?(Numeric) + return mismatched_type_details(default_value) + elsif flag_type == :object && !evaluation_detail.value.is_a?(Hash) && !evaluation_detail.value.is_a?(Array) + return mismatched_type_details(default_value) + end + + if flag_type == :integer + evaluation_detail = LaunchDarkly::EvaluationDetail.new( + evaluation_detail.value.to_i, + evaluation_detail.variation_index, + evaluation_detail.reason + ) + elsif flag_type == :float + evaluation_detail = LaunchDarkly::EvaluationDetail.new( + evaluation_detail.value.to_f, + evaluation_detail.variation_index, + evaluation_detail.reason + ) + end + + @details_converter.to_resolution_details(evaluation_detail) + end + + # + # @param default_value [any] + # + # @return [::OpenFeature::SDK::Provider::ResolutionDetails] + # + private def mismatched_type_details(default_value) + ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: ::OpenFeature::SDK::Provider::Reason::ERROR, + error_code: ::OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH + ) + end + end + end +end diff --git a/spec/impl/context_converter_spec.rb b/spec/impl/context_converter_spec.rb new file mode 100644 index 0000000..f9b3658 --- /dev/null +++ b/spec/impl/context_converter_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +RSpec.describe LaunchDarkly::OpenFeature::Impl::EvaluationContextConverter do + let(:log_output) { StringIO.new } + let(:logger) { Logger.new(log_output) } + let(:context_converter) { described_class.new(logger) } + + before do + log_output.reopen + end + + describe "key handling" do + it "creates context with only targeting key" do + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-key") + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.key).to eq('user-key') + expect(ld_context.kind).to eq('user') + end + + it "create context with only key" do + context = OpenFeature::SDK::EvaluationContext.new(key: "user-key") + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.key).to eq("user-key") + expect(ld_context.kind).to eq("user") + end + + it "targeting key takes precedence over attribute key" do + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "should-use", kind: "org", key: "do-not-use") + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.key).to eq("should-use") + expect(ld_context.kind).to eq("org") + end + + it "key replaces invalid targeting key" do + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: false, kind: "org", key: "fallback") + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.key).to eq("fallback") + expect(ld_context.kind).to eq("org") + end + + it "creates a context with an invalid targeting_key" do + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: false) + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(false) + expect(ld_context.key).to be_nil + + expect(log_output.string).to include("The EvaluationContext must contain either a 'targeting_key' or a 'key' and the type must be a string.") + end + + it "creates a context with an invalid key" do + context = OpenFeature::SDK::EvaluationContext.new(key: false) + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(false) + expect(ld_context.key).to be_nil + + expect(log_output.string).to include("A non-string 'key' attribute was provided.") + end + + end + + describe "kind handling" do + it "can specify kind" do + context = OpenFeature::SDK::EvaluationContext.new(key: "org-key", kind: "org") + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.key).to eq("org-key") + expect(ld_context.kind).to eq("org") + end + + it "invalid kind is discarded and reset to user" do + context = OpenFeature::SDK::EvaluationContext.new(key: "org-key", kind: false) + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.key).to eq("org-key") + expect(ld_context.kind).to eq("user") + end + end + + describe "attribute handling" do + it "test attributes are referenced correctly" do + context = OpenFeature::SDK::EvaluationContext.new(key: "user-key", kind: "user", anonymous: true, name: "Sandy", lastName: "Beaches") + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.key).to eq("user-key") + expect(ld_context.kind).to eq("user") + expect(ld_context[:anonymous]).to eq(true) + expect(ld_context[:name]).to eq("Sandy") + expect(ld_context[:lastName]).to eq("Beaches") + end + + it "invalid attributes are ignored" do + context = OpenFeature::SDK::EvaluationContext.new(key: "user-key", kind: "user", anonymous: true, name: 30, privateAttributes: "testing") + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.key).to eq("user-key") + expect(ld_context.kind).to eq("user") + expect(ld_context[:anonymous]).to eq(true) + expect(ld_context[:name]).to be_nil + expect(ld_context.private_attributes).to eq([]) + end + end + + describe "private attribute handling" do + it "private attributes are processed correctly" do + attributes = { + key: "user-key", + kind: "user", + address: { street: "123 Easy St", city: "Anytown" }, + name: "Sandy", + privateAttributes: ["name", "/address/city"], + } + context = OpenFeature::SDK::EvaluationContext.new(**attributes) + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.key).to eq("user-key") + expect(ld_context.kind).to eq("user") + expect(ld_context.private_attributes).to eq([LaunchDarkly::Reference.create("name"), LaunchDarkly::Reference.create("/address/city")]) + end + + it "ignores invalid private attribute types" do + context = OpenFeature::SDK::EvaluationContext.new(key: "user-key", privateAttributes: [true]) + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.key).to eq("user-key") + + expect(log_output.string).to include("'privateAttributes' must be an array of only string values") + end + end + + describe "multi kind context" do + it "can create multi kind context" do + attributes = { + kind: "multi", + user: { key: "user-key", name: "User name" }, + org: { key: "org-key", name: "Org name" }, + } + context = OpenFeature::SDK::EvaluationContext.new(**attributes) + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.multi_kind?).to eq(true) + + user_context = ld_context.individual_context("user") + expect(user_context).not_to be_nil + expect(user_context.key).to eq("user-key") + expect(user_context.kind).to eq("user") + expect(user_context[:name]).to eq("User name") + + org_context = ld_context.individual_context("org") + expect(org_context).not_to be_nil + expect(org_context.key).to eq("org-key") + expect(org_context.kind).to eq("org") + expect(org_context[:name]).to eq("Org name") + end + + it "multi kind context discards invalid single kind" do + attributes = { + kind: "multi", + user: false, + org: { key: "org-key", name: "Org name" }, + } + context = OpenFeature::SDK::EvaluationContext.new(**attributes) + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(true) + expect(ld_context.multi_kind?).to eq(false) + expect(ld_context.key).to eq("org-key") + expect(ld_context.kind).to eq("org") + expect(ld_context[:name]).to eq("Org name") + end + + it "handles all invalid single-kind contexts" do + attributes = { + kind: "multi", + user: "invalid format", + org: false, + } + context = OpenFeature::SDK::EvaluationContext.new(**attributes) + ld_context = context_converter.to_ld_context(context) + + expect(ld_context.valid?).to eq(false) + expect(ld_context.multi_kind?).to eq(false) + end + end +end diff --git a/spec/impl/details_converter_spec.rb b/spec/impl/details_converter_spec.rb new file mode 100644 index 0000000..5576eec --- /dev/null +++ b/spec/impl/details_converter_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe LaunchDarkly::OpenFeature::Impl::ResolutionDetailsConverter do + let(:details_converter) { described_class.new } + + [ + [LaunchDarkly::EvaluationReason::off, OpenFeature::SDK::Provider::Reason::DISABLED], + [LaunchDarkly::EvaluationReason::target_match, OpenFeature::SDK::Provider::Reason::TARGETING_MATCH], + [LaunchDarkly::EvaluationReason::error(LaunchDarkly::EvaluationReason::ERROR_MALFORMED_FLAG), OpenFeature::SDK::Provider::Reason::ERROR], + [LaunchDarkly::EvaluationReason::fallthrough, 'FALLTHROUGH'], + [LaunchDarkly::EvaluationReason::rule_match(0, 'rule id', false), 'RULE_MATCH'], + [LaunchDarkly::EvaluationReason::prerequisite_failed('failed-prereq'), 'PREREQUISITE_FAILED'], + ].each do |ld_reason, of_reason| + it "converts LD reason (#{ld_reason}) to OF reason (#{of_reason})" do + detail = LaunchDarkly::EvaluationDetail.new(true, 0, ld_reason) + resolution_details = details_converter.to_resolution_details(detail) + + expect(resolution_details.reason).to eq(of_reason) + end + end + + [ + [LaunchDarkly::EvaluationReason::ERROR_CLIENT_NOT_READY, OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY], + [LaunchDarkly::EvaluationReason::ERROR_FLAG_NOT_FOUND, OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND], + [LaunchDarkly::EvaluationReason::ERROR_MALFORMED_FLAG, OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR], + [LaunchDarkly::EvaluationReason::ERROR_USER_NOT_SPECIFIED, OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING], + [LaunchDarkly::EvaluationReason::ERROR_EXCEPTION, OpenFeature::SDK::Provider::ErrorCode::GENERAL], + ].each do |ld_error_kind, of_error_code| + it "converts error kind (#{ld_error_kind}) to OF error code (#{of_error_code})" do + detail = LaunchDarkly::EvaluationDetail.new(true, 0, LaunchDarkly::EvaluationReason::error(ld_error_kind)) + resolution_details = details_converter.to_resolution_details(detail) + + expect(resolution_details.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(resolution_details.error_code).to eq(of_error_code) + end + end +end diff --git a/spec/provider_spec.rb b/spec/provider_spec.rb new file mode 100644 index 0000000..83e9f36 --- /dev/null +++ b/spec/provider_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +RSpec.describe LaunchDarkly::OpenFeature::Provider do + let(:td) { + td = LaunchDarkly::Integrations::TestData.data_source + td.update(td.flag("fallthrough-boolean").variation_for_all(true)) + td + } + let(:evaluation_context) { OpenFeature::SDK::EvaluationContext.new(key: "user-key") } + let(:config) { LaunchDarkly::Config.new(data_source: td) } + let(:provider) { described_class.new("example-key", config) } + + it "metadata is set correctly" do + expect(provider.metadata.name).to eq("launchdarkly-openfeature-server") + end + + it "not providing context returns error" do + resolution_details = provider.fetch_boolean_value(flag_key: "flag-key", default_value: true) + + expect(resolution_details.value).to eq(true) + expect(resolution_details.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(resolution_details.variant).to be_nil + expect(resolution_details.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING) + end + + it "evaluation results are converted to details" do + resolution_details = provider.fetch_boolean_value(flag_key: "fallthrough-boolean", default_value: true, evaluation_context: evaluation_context) + + expect(resolution_details.value).to eq(true) + expect(resolution_details.reason).to eq("FALLTHROUGH") + expect(resolution_details.variant).to eq("0") + expect(resolution_details.error_code).to be_nil + end + + it "evaluation error results are converted correctly" do + detail = LaunchDarkly::EvaluationDetail.new(true, nil, LaunchDarkly::EvaluationReason.error(LaunchDarkly::EvaluationReason::ERROR_FLAG_NOT_FOUND)) + allow(LaunchDarkly::LDClient).to receive(:variation_detail).and_return(detail) + resolution_details = provider.fetch_boolean_value(flag_key: "flag-key", default_value: true, evaluation_context: evaluation_context) + + expect(resolution_details.value).to eq(true) + expect(resolution_details.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(resolution_details.variant).to be_nil + expect(resolution_details.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) + end + + it "invalid types generate type mismatch results" do + resolution_details = provider.fetch_string_value(flag_key: "fallthrough-boolean", default_value: "default-value", evaluation_context: evaluation_context) + + expect(resolution_details.value).to eq("default-value") + expect(resolution_details.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(resolution_details.variant).to be_nil + expect(resolution_details.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + end + + [ + [true, false, false, :fetch_boolean_value], + [false, true, true, :fetch_boolean_value], + [false, 1, false, :fetch_boolean_value], + [false, "True", false, :fetch_boolean_value], + [true, [], true, :fetch_boolean_value], + + ['default-string', 'return-string', 'return-string', :fetch_string_value], + ['default-string', 1, 'default-string', :fetch_string_value], + ['default-string', true, 'default-string', :fetch_string_value], + + [1, 2, 2, :fetch_integer_value], + [1, 2.0, 2, :fetch_integer_value], + [1, true, 1, :fetch_integer_value], + [1, false, 1, :fetch_integer_value], + [1, "", 1, :fetch_integer_value], + + [1.0, 2.0, 2.0, :fetch_float_value], + [1.0, 2, 2.0, :fetch_float_value], + [1.0, true, 1.0, :fetch_float_value], + [1.0, 'return-string', 1.0, :fetch_float_value], + + [['default-value'], ['return-string'], ['return-string'], :fetch_object_value], + [['default-value'], true, ['default-value'], :fetch_object_value], + [['default-value'], 1, ['default-value'], :fetch_object_value], + [['default-value'], 'return-string', ['default-value'], :fetch_object_value], + ].each do |default_value, return_value, expected_value, method_name| + it "check method and result match type" do + td.update(td.flag("check-method-flag").variations(return_value).variation_for_all(0)) + + method = provider.method(method_name) + resolution_details = method.call(flag_key: "check-method-flag", default_value: default_value, evaluation_context: evaluation_context) + + expect(resolution_details.value).to eq(expected_value) + end + end +end