-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add initial implementation of provider (#1)
- Loading branch information
Showing
9 changed files
with
681 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,3 +9,5 @@ | |
|
||
# rspec failure tracking | ||
.rspec_status | ||
|
||
Gemfile.lock |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,2 @@ | ||
--format documentation | ||
--color | ||
--require spec_helper |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.