Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add initial implementation of provider #1

Merged
merged 2 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 .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)
kinyoklion marked this conversation as resolved.
Show resolved Hide resolved
@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
100 changes: 100 additions & 0 deletions lib/ldclient-openfeature/provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# frozen_string_literal: true

require 'ldclient-rb'
require 'open_feature/sdk'

module LaunchDarkly
module OpenFeature
class Provider
attr_reader :metadata

#
# @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)
kinyoklion marked this conversation as resolved.
Show resolved Hide resolved

@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)
kinyoklion marked this conversation as resolved.
Show resolved Hide resolved
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 flag_type == :integer && !evaluation_detail.value.is_a?(Integer)
Copy link
Member

Choose a reason for hiding this comment

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

I think we would want coercion on either a float or an int to either type. To prevent unexpected behavior when some values happen to be integers and then aren't.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure I follow. Are you suggesting that the return value from this method call should be cast to an int, or that I should allow both floats and ints, but cast that value?

Copy link
Member

Choose a reason for hiding this comment

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

So, I am not familiar with ruby enough to know for sure how it works.

But generally speaking LD supports only numbers (which are represented as JSON, which is a double). Not doubles or ints. So if you evaluate a number as an int it should be cast to an int, and if you evaluate it as a double, then it should be the number.

return mismatched_type_details(default_value)
elsif flag_type == :float && !evaluation_detail.value.is_a?(Float)
return mismatched_type_details(default_value)
elsif flag_type == :number && !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

@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