Skip to content

Commit

Permalink
feat: Add initial implementation of provider (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
keelerm84 authored Jul 16, 2024
1 parent cfce9b9 commit 7550e14
Show file tree
Hide file tree
Showing 9 changed files with 681 additions and 13 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@

# rspec failure tracking
.rspec_status

Gemfile.lock
1 change: 0 additions & 1 deletion .rspec
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
--format documentation
--color
--require spec_helper
16 changes: 4 additions & 12 deletions lib/ldclient-openfeature.rb
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
# 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
#
# 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
147 changes: 147 additions & 0 deletions lib/ldclient-openfeature/impl/context_converter.rb
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions lib/ldclient-openfeature/impl/details_converter.rb
Original file line number Diff line number Diff line change
@@ -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
113 changes: 113 additions & 0 deletions lib/ldclient-openfeature/provider.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 7550e14

Please sign in to comment.