Skip to content

Commit 7550e14

Browse files
authored
feat: Add initial implementation of provider (#1)
1 parent cfce9b9 commit 7550e14

9 files changed

+681
-13
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@
99

1010
# rspec failure tracking
1111
.rspec_status
12+
13+
Gemfile.lock

.rspec

-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
--format documentation
21
--color
32
--require spec_helper

lib/ldclient-openfeature.rb

+4-12
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,16 @@
11
# frozen_string_literal: true
22

3+
require_relative "ldclient-openfeature/impl/context_converter"
4+
require_relative "ldclient-openfeature/impl/details_converter"
5+
require_relative "ldclient-openfeature/provider"
36
require_relative "ldclient-openfeature/version"
7+
48
require "logger"
59

610
module LaunchDarkly
711
#
812
# Namespace for the LaunchDarkly OpenFeature provider.
913
#
1014
module OpenFeature
11-
#
12-
# @return [Logger] the Rails logger if in Rails, or a default Logger at WARN level otherwise
13-
#
14-
def self.default_logger
15-
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
16-
Rails.logger
17-
else
18-
log = ::Logger.new($stdout)
19-
log.level = ::Logger::WARN
20-
log
21-
end
22-
end
2315
end
2416
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# frozen_string_literal: true
2+
3+
require 'ldclient-rb'
4+
require 'open_feature/sdk'
5+
6+
module LaunchDarkly
7+
module OpenFeature
8+
module Impl
9+
class EvaluationContextConverter
10+
#
11+
# @param logger [Logger]
12+
#
13+
def initialize(logger)
14+
@logger = logger
15+
end
16+
17+
#
18+
# Create an LDContext from an EvaluationContext.
19+
#
20+
# A context will always be created, but the created context may be invalid. Log messages will be written to
21+
# indicate the source of the problem.
22+
#
23+
# @param context [OpenFeature::SDK::EvaluationContext]
24+
#
25+
# @return [LaunchDarkly::LDContext]
26+
#
27+
def to_ld_context(context)
28+
kind = context.field('kind')
29+
30+
return build_multi_context(context) if kind == "multi"
31+
32+
unless kind.nil? || kind.is_a?(String)
33+
@logger.warn("'kind' was set to a non-string value; defaulting to user")
34+
kind = 'user'
35+
end
36+
37+
targeting_key = context.targeting_key
38+
key = context.field('key')
39+
targeting_key = get_targeting_key(targeting_key, key)
40+
41+
kind ||= 'user'
42+
build_single_context(context.fields, kind, targeting_key)
43+
end
44+
45+
#
46+
# @param targeting_key [String, nil]
47+
# @param key [any]
48+
#
49+
# @return [String]
50+
#
51+
private def get_targeting_key(targeting_key, key)
52+
# The targeting key may be set but empty. So we want to treat an empty string as a not defined one. Later it
53+
# could become null, so we will need to check that.
54+
if !targeting_key.nil? && targeting_key != "" && key.is_a?(String)
55+
# There is both a targeting key and a key. It will work, but probably is not intentional.
56+
@logger.warn("EvaluationContext contained both a 'key' and 'targeting_key'.")
57+
end
58+
59+
@logger.warn("A non-string 'key' attribute was provided.") unless key.nil? || key.is_a?(String)
60+
61+
targeting_key ||= key unless key.nil? || !key.is_a?(String)
62+
63+
if targeting_key.nil? || targeting_key == "" || !targeting_key.is_a?(String)
64+
@logger.error("The EvaluationContext must contain either a 'targeting_key' or a 'key' and the type must be a string.")
65+
end
66+
67+
targeting_key || ""
68+
end
69+
70+
#
71+
# @param context [OpenFeature::SDK::EvaluationContext]
72+
#
73+
# @return [LaunchDarkly::LDContext]
74+
#
75+
private def build_multi_context(context)
76+
contexts = []
77+
78+
context.fields.each do |kind, attributes|
79+
next if kind == 'kind'
80+
81+
unless attributes.is_a?(Hash)
82+
@logger.warn("Top level attributes in a multi-kind context should be dictionaries")
83+
next
84+
end
85+
86+
key = attributes.fetch(:key, nil)
87+
targeting_key = attributes.fetch(:targeting_key, nil)
88+
89+
next unless targeting_key.nil? || targeting_key.is_a?(String)
90+
91+
targeting_key = get_targeting_key(targeting_key, key)
92+
single_context = build_single_context(attributes, kind, targeting_key)
93+
94+
contexts << single_context
95+
end
96+
97+
LaunchDarkly::LDContext.create_multi(contexts)
98+
end
99+
100+
#
101+
# @param attributes [Hash]
102+
# @param kind [String]
103+
# @param key [String]
104+
#
105+
# @return [LaunchDarkly::LDContext]
106+
#
107+
private def build_single_context(attributes, kind, key)
108+
context = { kind: kind, key: key }
109+
110+
attributes.each do |k, v|
111+
next if %w[key targeting_key kind].include? k
112+
113+
if k == 'name' && v.is_a?(String)
114+
context[:name] = v
115+
elsif k == 'name'
116+
@logger.error("The attribute 'name' must be a string")
117+
next
118+
elsif k == 'anonymous' && [true, false].include?(v)
119+
context[:anonymous] = v
120+
elsif k == 'anonymous'
121+
@logger.error("The attribute 'anonymous' must be a boolean")
122+
next
123+
elsif k == 'privateAttributes' && v.is_a?(Array)
124+
private_attributes = []
125+
v.each do |private_attribute|
126+
unless private_attribute.is_a?(String)
127+
@logger.error("'privateAttributes' must be an array of only string values")
128+
next
129+
end
130+
131+
private_attributes << private_attribute
132+
end
133+
134+
context[:_meta] = { privateAttributes: private_attributes } unless private_attributes.empty?
135+
elsif k == 'privateAttributes'
136+
@logger.error("The attribute 'privateAttributes' must be an array")
137+
else
138+
context[k.to_sym] = v
139+
end
140+
end
141+
142+
LaunchDarkly::LDContext.create(context)
143+
end
144+
end
145+
end
146+
end
147+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# frozen_string_literal: true
2+
3+
require 'ldclient-rb'
4+
require 'open_feature/sdk'
5+
6+
module LaunchDarkly
7+
module OpenFeature
8+
module Impl
9+
class ResolutionDetailsConverter
10+
#
11+
# @param detail [LaunchDarkly::EvaluationDetail]
12+
#
13+
# @return [OpenFeature::SDK::ResolutionDetails]
14+
#
15+
def to_resolution_details(detail)
16+
value = detail.value
17+
is_default = detail.variation_index.nil?
18+
variation_index = detail.variation_index
19+
20+
reason = detail.reason
21+
reason_kind = reason.kind
22+
23+
openfeature_reason = kind_to_reason(reason_kind)
24+
25+
openfeature_error_code = nil
26+
if reason_kind == LaunchDarkly::EvaluationReason::ERROR
27+
openfeature_error_code = error_kind_to_code(reason.error_kind)
28+
end
29+
30+
openfeature_variant = nil
31+
openfeature_variant = variation_index.to_s unless is_default
32+
33+
::OpenFeature::SDK::Provider::ResolutionDetails.new(
34+
value: value,
35+
error_code: openfeature_error_code,
36+
error_message: nil,
37+
reason: openfeature_reason,
38+
variant: openfeature_variant
39+
)
40+
end
41+
42+
#
43+
# @param kind [Symbol]
44+
#
45+
# @return [String]
46+
#
47+
private def kind_to_reason(kind)
48+
case kind
49+
when LaunchDarkly::EvaluationReason::OFF
50+
::OpenFeature::SDK::Provider::Reason::DISABLED
51+
when LaunchDarkly::EvaluationReason::TARGET_MATCH
52+
::OpenFeature::SDK::Provider::Reason::TARGETING_MATCH
53+
when LaunchDarkly::EvaluationReason::ERROR
54+
::OpenFeature::SDK::Provider::Reason::ERROR
55+
else
56+
# NOTE: FALLTHROUGH, RULE_MATCH, PREREQUISITE_FAILED intentionally
57+
kind.to_s
58+
end
59+
end
60+
61+
#
62+
# @param error_kind [Symbol]
63+
#
64+
# @return [String]
65+
#
66+
private def error_kind_to_code(error_kind)
67+
return ::OpenFeature::SDK::Provider::ErrorCode::GENERAL if error_kind.nil?
68+
69+
case error_kind
70+
when LaunchDarkly::EvaluationReason::ERROR_CLIENT_NOT_READY
71+
::OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY
72+
when LaunchDarkly::EvaluationReason::ERROR_FLAG_NOT_FOUND
73+
::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND
74+
when LaunchDarkly::EvaluationReason::ERROR_MALFORMED_FLAG
75+
::OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR
76+
when LaunchDarkly::EvaluationReason::ERROR_USER_NOT_SPECIFIED
77+
::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING
78+
else
79+
# NOTE: EXCEPTION_ERROR intentionally omitted
80+
::OpenFeature::SDK::Provider::ErrorCode::GENERAL
81+
end
82+
end
83+
end
84+
end
85+
end
86+
end

lib/ldclient-openfeature/provider.rb

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# frozen_string_literal: true
2+
3+
require 'ldclient-rb'
4+
require 'open_feature/sdk'
5+
6+
module LaunchDarkly
7+
module OpenFeature
8+
class Provider
9+
attr_reader :metadata
10+
11+
NUMERIC_TYPES = %i[integer float number].freeze
12+
private_constant :NUMERIC_TYPES
13+
14+
#
15+
# @param sdk_key [String]
16+
# @param config [LaunchDarkly::Config]
17+
# @param wait_for_seconds [Float]
18+
#
19+
def initialize(sdk_key, config, wait_for_seconds = 5)
20+
@client = LaunchDarkly::LDClient.new(sdk_key, config, wait_for_seconds)
21+
22+
@context_converter = Impl::EvaluationContextConverter.new(config.logger)
23+
@details_converter = Impl::ResolutionDetailsConverter.new
24+
25+
@metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: "launchdarkly-openfeature-server").freeze
26+
end
27+
28+
def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
29+
resolve_value(:boolean, flag_key, default_value, evaluation_context)
30+
end
31+
32+
def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
33+
resolve_value(:string, flag_key, default_value, evaluation_context)
34+
end
35+
36+
def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
37+
resolve_value(:number, flag_key, default_value, evaluation_context)
38+
end
39+
40+
def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
41+
resolve_value(:integer, flag_key, default_value, evaluation_context)
42+
end
43+
44+
def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
45+
resolve_value(:float, flag_key, default_value, evaluation_context)
46+
end
47+
48+
def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
49+
resolve_value(:object, flag_key, default_value, evaluation_context)
50+
end
51+
52+
#
53+
# @param flag_type [Symbol]
54+
# @param flag_key [String]
55+
# @param default_value [any]
56+
# @param evaluation_context [::OpenFeature::SDK::EvaluationContext, nil]
57+
#
58+
# @return [::OpenFeature::SDK::Provider::ResolutionDetails]
59+
#
60+
private def resolve_value(flag_type, flag_key, default_value, evaluation_context)
61+
if evaluation_context.nil?
62+
return ::OpenFeature::SDK::Provider::ResolutionDetails.new(
63+
value: default_value,
64+
reason: ::OpenFeature::SDK::Provider::Reason::ERROR,
65+
error_code: ::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING
66+
)
67+
end
68+
69+
ld_context = @context_converter.to_ld_context(evaluation_context)
70+
evaluation_detail = @client.variation_detail(flag_key, ld_context, default_value)
71+
72+
if flag_type == :boolean && ![true, false].include?(evaluation_detail.value)
73+
return mismatched_type_details(default_value)
74+
elsif flag_type == :string && !evaluation_detail.value.is_a?(String)
75+
return mismatched_type_details(default_value)
76+
elsif NUMERIC_TYPES.include?(flag_type) && !evaluation_detail.value.is_a?(Numeric)
77+
return mismatched_type_details(default_value)
78+
elsif flag_type == :object && !evaluation_detail.value.is_a?(Hash) && !evaluation_detail.value.is_a?(Array)
79+
return mismatched_type_details(default_value)
80+
end
81+
82+
if flag_type == :integer
83+
evaluation_detail = LaunchDarkly::EvaluationDetail.new(
84+
evaluation_detail.value.to_i,
85+
evaluation_detail.variation_index,
86+
evaluation_detail.reason
87+
)
88+
elsif flag_type == :float
89+
evaluation_detail = LaunchDarkly::EvaluationDetail.new(
90+
evaluation_detail.value.to_f,
91+
evaluation_detail.variation_index,
92+
evaluation_detail.reason
93+
)
94+
end
95+
96+
@details_converter.to_resolution_details(evaluation_detail)
97+
end
98+
99+
#
100+
# @param default_value [any]
101+
#
102+
# @return [::OpenFeature::SDK::Provider::ResolutionDetails]
103+
#
104+
private def mismatched_type_details(default_value)
105+
::OpenFeature::SDK::Provider::ResolutionDetails.new(
106+
value: default_value,
107+
reason: ::OpenFeature::SDK::Provider::Reason::ERROR,
108+
error_code: ::OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH
109+
)
110+
end
111+
end
112+
end
113+
end

0 commit comments

Comments
 (0)