Skip to content

Commit 8dbd038

Browse files
feat!: Upgrade to LaunchDarkly.ServerSdk 7.0.0+ and implement support for building contexts. (#16)
Co-authored-by: Matthew M. Keeler <[email protected]>
1 parent fca426d commit 8dbd038

File tree

10 files changed

+265
-84
lines changed

10 files changed

+265
-84
lines changed

README.md

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,97 @@ For information on using the OpenFeature client please refer to the [OpenFeature
5050

5151
## OpenFeature Specific Considerations
5252

53-
When evaluating a `User` with the LaunchDarkly Server-Side SDK for .NET a string `key` attribute would normally be required. When using OpenFeature the `targetingKey` attribute should be used instead of `key`. If a `key` attribute is provided in the `EvaluationContext`, then it will be discarded in favor of `targetingKey`. If a `targetingKey` is not provided, or if the `EvaluationContext` is omitted entirely, then the `defaultValue` will be returned from OpenFeature evaluation methods.
53+
LaunchDarkly evaluates contexts, and it can either evaluate a single-context, or a multi-context. When using OpenFeature both single and multi-contexts must be encoded into a single `EvaluationContext`. This is accomplished by looking for an attribute named `kind` in the `EvaluationContext`.
5454

55-
Other fields normally included in a `User` may be added to the `EvaluationContext`. Any `custom` attributes can be added to the top level of the evaluation context, and they will operate as if they were `custom` attributes on an `User`. Attributes which are typically top level on an `LDUser` should be of the same types that are specified for a `User` or they will not operate as intended.
55+
There are 4 different scenarios related to the `kind`:
56+
1. There is no `kind` attribute. In this case the provider will treat the context as a single context containing a "user" kind.
57+
2. There is a `kind` attribute, and the value of that attribute is "multi". This will indicate to the provider that the context is a multi-context.
58+
3. There is a `kind` attribute, and the value of that attribute is a string other than "multi". This will indicate to the provider a single context of the kind specified.
59+
4. There is a `kind` attribute, and the attribute is not a string. In this case the value of the attribute will be discarded, and the context will be treated as a "user". An error message will be logged.
5660

57-
If a top level `custom` attribute is defined on the `EvaluationContext`, then that will be a `custom` attribute inside `custom` for a `User`.
61+
The `kind` attribute should be a string containing only contain ASCII letters, numbers, `.`, `_` or `-`.
62+
63+
The OpenFeature specification allows for an optional targeting key, but LaunchDarkly requires a key for evaluation. A targeting key must be specified for each context being evaluated. It may be specified using either `targetingKey`, as it is in the OpenFeature specification, or `key`, which is the typical LaunchDarkly identifier for the targeting key. If a `targetingKey` and a `key` are specified, then the `targetingKey` will take precedence.
64+
65+
There are several other attributes which have special functionality within a single or multi-context.
66+
- A key of `privateAttributes`. Must be an array of string values. [Equivalent to the 'Private' builder method in the SDK.](https://launchdarkly.github.io/dotnet-server-sdk/api/LaunchDarkly.Sdk.ContextBuilder.html#LaunchDarkly_Sdk_ContextBuilder_Private_System_String___)
67+
- A key of `anonymous`. Must be a boolean value. [Equivalent to the 'Anonymous' builder method in the SDK.](https://launchdarkly.github.io/dotnet-server-sdk/api/LaunchDarkly.Sdk.Context.html#LaunchDarkly_Sdk_Context_Anonymous)
68+
- A key of `name`. Must be a string. [Equivalent to the 'Name' builder method in the SDK.](https://launchdarkly.github.io/dotnet-server-sdk/api/LaunchDarkly.Sdk.ContextBuilder.html#LaunchDarkly_Sdk_ContextBuilder_Name_System_String_)
69+
70+
### Examples
71+
72+
#### A single user context
73+
74+
```csharp
75+
var evaluationContext = EvaluationContext.Builder()
76+
.Set("targetingKey", "my-user-key") // Could also use "key" instead of "targetingKey".
77+
.Build();
78+
```
79+
80+
#### A single context of kind "organization"
81+
82+
```csharp
83+
var evaluationContext = EvaluationContext.Builder()
84+
.Set("kind", "organization")
85+
.Set("targetingKey", "my-org-key") // Could also use "key" instead of "targetingKey".
86+
.Build();
87+
```
88+
89+
#### A multi-context containing a "user" and an "organization"
90+
91+
```csharp
92+
var evaluationContext = EvaluationContext.Builder()
93+
.Set("kind", "multi") // Lets the provider know this is a multi-context
94+
// Every other top level attribute should be a structure representing
95+
// individual contexts of the multi-context.
96+
// (non-conforming attributes will be ignored and a warning logged).
97+
.Set("organization", new Structure(new Dictionary<string, Value>
98+
{
99+
{"targetingKey", new Value("my-org-key")},
100+
{"name", new Value("the-org-name")},
101+
{"myCustomAttribute", new Value("myAttributeValue")}
102+
}))
103+
.Set("user", new Structure(new Dictionary<string, Value> {
104+
{"targetingKey", new Value("my-user-key")},
105+
}))
106+
.Build();
107+
```
108+
109+
#### Setting private attributes in a single context
110+
111+
```csharp
112+
var evaluationContext = EvaluationContext.Builder()
113+
.Set("kind", "organization")
114+
.Set("name", "the-org-name")
115+
.Set("targetingKey", "my-org-key")
116+
.Set("anonymous", true)
117+
.Set("myCustomAttribute", "myCustomValue")
118+
.Set("privateAttributes", new Value(new List<Value>{new Value("myCustomAttribute")}))
119+
.Build();
120+
```
121+
122+
#### Setting private attributes in a multi-context
123+
124+
```csharp
125+
var evaluationContext = EvaluationContext.Builder()
126+
.Set("kind", "multi")
127+
.Set("organization", new Structure(new Dictionary<string, Value>
128+
{
129+
{"targetingKey", new Value("my-org-key")},
130+
{"name", new Value("the-org-name")},
131+
// This will ONLY apply to the "organization" attributes.
132+
{"privateAttributes", new Value(new List<Value>{new Value("myCustomAttribute")})}
133+
// This attribute will be private.
134+
{"myCustomAttribute", new Value("myAttributeValue")},
135+
}))
136+
.Set("user", new Structure(new Dictionary<string, Value> {
137+
{"targetingKey", new Value("my-user-key")},
138+
{"anonymous", new Value(true)},
139+
// This attribute will not be private.
140+
{"myCustomAttribute", new Value("myAttributeValue")},
141+
}))
142+
.Build();
143+
```
58144

59145
## Learn more
60146

src/LaunchDarkly.OpenFeature.ServerProvider/EvalContextConverter.cs

Lines changed: 87 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.Immutable;
4+
using System.Linq;
25
using LaunchDarkly.Logging;
36
using LaunchDarkly.Sdk;
47
using OpenFeature.Model;
58

69
namespace LaunchDarkly.OpenFeature.ServerProvider
710
{
811
/// <summary>
9-
/// Class which converts <see cref="EvaluationContext"/> objects into <see cref="User"/> objects.
12+
/// Class which converts <see cref="EvaluationContext"/> objects into <see cref="Context"/> objects.
1013
/// </summary>
1114
internal class EvalContextConverter
1215
{
@@ -39,7 +42,7 @@ private static string InvalidTypeMessage(string attribute, string type) => $"The
3942
/// A method to call with the extracted value.
4043
/// This will only be called if the type was correct.
4144
/// </param>
42-
private void Extract(string key, LdValue value, Func<string, IUserBuilder> setter)
45+
private void Extract(string key, LdValue value, Func<string, ContextBuilder> setter)
4346
{
4447
if (value.IsNull)
4548
{
@@ -65,7 +68,7 @@ private void Extract(string key, LdValue value, Func<string, IUserBuilder> sette
6568
/// A method to call with the extracted value.
6669
/// This will only be called if the type was correct.
6770
/// </param>
68-
private void Extract(string key, LdValue value, Func<bool, IUserBuilder> setter)
71+
private void Extract(string key, LdValue value, Func<bool, ContextBuilder> setter)
6972
{
7073
if (value.IsNull)
7174
{
@@ -83,12 +86,12 @@ private void Extract(string key, LdValue value, Func<bool, IUserBuilder> setter)
8386
}
8487

8588
/// <summary>
86-
/// Extract a value and add it to a user builder.
89+
/// Extract a value and add it to a context builder.
8790
/// </summary>
88-
/// <param name="key">The key to add to the user if the value can be extracted</param>
91+
/// <param name="key">The key to add to the context if the value can be extracted</param>
8992
/// <param name="value">The value to extract</param>
90-
/// <param name="builder">The user builder to add the value to</param>
91-
private void ProcessValue(string key, Value value, IUserBuilder builder)
93+
/// <param name="builder">The context builder to add the value to</param>
94+
private void ProcessValue(string key, Value value, ContextBuilder builder)
9295
{
9396
var ldValue = value.ToLdValue();
9497

@@ -98,50 +101,95 @@ private void ProcessValue(string key, Value value, IUserBuilder builder)
98101
case "targetingKey":
99102
case "key":
100103
break;
101-
case "secondary":
102-
Extract(key, ldValue, builder.Secondary);
103-
break;
104104
case "name":
105105
Extract(key, ldValue, builder.Name);
106106
break;
107-
case "firstName":
108-
Extract(key, ldValue, builder.FirstName);
109-
break;
110-
case "lastName":
111-
Extract(key, ldValue, builder.LastName);
112-
break;
113-
case "email":
114-
Extract(key, ldValue, builder.Email);
115-
break;
116-
case "avatar":
117-
Extract(key, ldValue, builder.Avatar);
118-
break;
119-
case "ip":
120-
Extract(key, ldValue, builder.IPAddress);
121-
break;
122-
case "country":
123-
Extract(key, ldValue, builder.Country);
124-
break;
125107
case "anonymous":
126108
Extract(key, ldValue, builder.Anonymous);
127109
break;
110+
case "privateAttributes":
111+
builder.Private(ldValue.AsList(LdValue.Convert.String).ToArray());
112+
break;
128113
default:
129114
// Was not a built-in attribute.
130-
builder.Custom(key, ldValue);
115+
builder.Set(key, ldValue);
131116
break;
132117
}
133118
}
134119

135120
/// <summary>
136-
/// Convert an <see cref="EvaluationContext"/> into a <see cref="User"/>.
121+
/// Convert an <see cref="EvaluationContext"/> into a <see cref="Context"/>.
122+
/// </summary>
123+
/// <param name="evaluationContext">The evaluation context to convert</param>
124+
/// <returns>A converted context</returns>
125+
public Context ToLdContext(EvaluationContext evaluationContext)
126+
{
127+
// Use the kind to determine the evaluation context shape.
128+
// If there is no kind at all, then we make a single context of "user" kind.
129+
evaluationContext.TryGetValue("kind", out var kind);
130+
131+
var kindString = "user";
132+
// A multi-context.
133+
if (kind != null && kind.AsString == "multi")
134+
{
135+
return BuildMultiLdContext(evaluationContext);
136+
}
137+
// Single context with specified kind.
138+
else if (kind != null && kind.IsString)
139+
{
140+
kindString = kind.AsString;
141+
}
142+
// The kind was not a string.
143+
else if (kind != null && !kind.IsString)
144+
{
145+
_log.Warn("The EvaluationContext contained an invalid kind and it will be discarded.");
146+
}
147+
// Else, there is no kind, so we are going to assume a user.
148+
149+
return BuildSingleLdContext(evaluationContext.AsDictionary(), kindString);
150+
}
151+
152+
/// <summary>
153+
/// Convert an evaluation context into a multi-context.
137154
/// </summary>
138155
/// <param name="evaluationContext">The evaluation context to convert</param>
139-
/// <returns>A converted user</returns>
140-
public User ToLdUser(EvaluationContext evaluationContext)
156+
/// <returns>A converted multi-context</returns>
157+
private Context BuildMultiLdContext(EvaluationContext evaluationContext)
158+
{
159+
var multiBuilder = Context.MultiBuilder();
160+
foreach (var pair in evaluationContext.AsDictionary())
161+
{
162+
// Don't need to inspect the "kind" key.
163+
if (pair.Key == "kind") continue;
164+
165+
var kind = pair.Key;
166+
var attributes = pair.Value;
167+
168+
if (!attributes.IsStructure)
169+
{
170+
_log.Warn("Top level attributes in a multi-kind context should be Structure types.");
171+
continue;
172+
}
173+
174+
multiBuilder.Add(BuildSingleLdContext(attributes.AsStructure.AsDictionary(), kind));
175+
}
176+
177+
178+
return multiBuilder.Build();
179+
}
180+
181+
/// <summary>
182+
/// Construct a single context from an immutable dictionary of attributes.
183+
/// This can either be the entirety of a single context, or a part of a multi-context.
184+
/// </summary>
185+
/// <param name="attributes">The attributes to use when building the context</param>
186+
/// <param name="kindString">The kind of the built context</param>
187+
/// <returns>A converted context</returns>
188+
private Context BuildSingleLdContext(IImmutableDictionary<string, Value> attributes, string kindString)
141189
{
142-
// targetingKey is the specification, so it takes precedence.
143-
evaluationContext.TryGetValue("key", out var keyAttr);
144-
evaluationContext.TryGetValue("targetingKey", out var targetingKey);
190+
// targetingKey is in the specification, so it takes precedence.
191+
attributes.TryGetValue("key", out var keyAttr);
192+
attributes.TryGetValue("targetingKey", out var targetingKey);
145193
var finalKey = (targetingKey ?? keyAttr)?.AsString;
146194

147195
if (keyAttr != null && targetingKey != null)
@@ -156,16 +204,16 @@ public User ToLdUser(EvaluationContext evaluationContext)
156204
"must be a string.");
157205
}
158206

159-
var userBuilder = User.Builder(finalKey);
160-
foreach (var kvp in evaluationContext)
207+
var contextBuilder = Context.Builder(ContextKind.Of(kindString), finalKey);
208+
foreach (var kvp in attributes)
161209
{
162210
var key = kvp.Key;
163211
var value = kvp.Value;
164212

165-
ProcessValue(key, value, userBuilder);
213+
ProcessValue(key, value, contextBuilder);
166214
}
167215

168-
return userBuilder.Build();
216+
return contextBuilder.Build();
169217
}
170218
}
171219
}

src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarkly.OpenFeature.ServerProvider.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
</ItemGroup>
3939

4040
<ItemGroup>
41-
<PackageReference Include="LaunchDarkly.ServerSdk" Version="[6.3.2,7.0)" />
41+
<PackageReference Include="LaunchDarkly.ServerSdk" Version="[7.0,8.0)" />
4242
<PackageReference Include="OpenFeature" Version="[1.0.0, 2.0.0)" />
4343
</ItemGroup>
4444

src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using LaunchDarkly.Sdk;
44
using LaunchDarkly.Sdk.Server;
55
using LaunchDarkly.Sdk.Server.Interfaces;
6+
using LaunchDarkly.Sdk.Server.Subsystems;
67
using OpenFeature;
78
using OpenFeature.Model;
89

@@ -39,7 +40,7 @@ public Provider(ILdClient client, ProviderConfiguration config = null)
3940
{
4041
_client = client;
4142
var logConfig = (config?.LoggingConfigurationFactory ?? Components.Logging())
42-
.CreateLoggingConfiguration();
43+
.Build(null);
4344

4445
// If there is a base name for the logger, then use the namespace as the name.
4546
var log = logConfig.LogAdapter.Logger(logConfig.BaseLoggerName != null
@@ -56,31 +57,31 @@ public Provider(ILdClient client, ProviderConfiguration config = null)
5657
/// <inheritdoc />
5758
public override Task<ResolutionDetails<bool>> ResolveBooleanValue(string flagKey, bool defaultValue,
5859
EvaluationContext context = null) => Task.FromResult(_client
59-
.BoolVariationDetail(flagKey, _contextConverter.ToLdUser(context), defaultValue)
60+
.BoolVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
6061
.ToResolutionDetails(flagKey));
6162

6263
/// <inheritdoc />
6364
public override Task<ResolutionDetails<string>> ResolveStringValue(string flagKey, string defaultValue,
6465
EvaluationContext context = null) => Task.FromResult(_client
65-
.StringVariationDetail(flagKey, _contextConverter.ToLdUser(context), defaultValue)
66+
.StringVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
6667
.ToResolutionDetails(flagKey));
6768

6869
/// <inheritdoc />
6970
public override Task<ResolutionDetails<int>> ResolveIntegerValue(string flagKey, int defaultValue,
7071
EvaluationContext context = null) => Task.FromResult(_client
71-
.IntVariationDetail(flagKey, _contextConverter.ToLdUser(context), defaultValue)
72+
.IntVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
7273
.ToResolutionDetails(flagKey));
7374

7475
/// <inheritdoc />
7576
public override Task<ResolutionDetails<double>> ResolveDoubleValue(string flagKey, double defaultValue,
7677
EvaluationContext context = null) => Task.FromResult(_client
77-
.DoubleVariationDetail(flagKey, _contextConverter.ToLdUser(context), defaultValue)
78+
.DoubleVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
7879
.ToResolutionDetails(flagKey));
7980

8081
/// <inheritdoc />
8182
public override Task<ResolutionDetails<Value>> ResolveStructureValue(string flagKey, Value defaultValue,
8283
EvaluationContext context = null) => Task.FromResult(_client
83-
.JsonVariationDetail(flagKey, _contextConverter.ToLdUser(context), LdValue.Null)
84+
.JsonVariationDetail(flagKey, _contextConverter.ToLdContext(context), LdValue.Null)
8485
.ToValueDetail(defaultValue).ToResolutionDetails(flagKey));
8586

8687
#endregion

src/LaunchDarkly.OpenFeature.ServerProvider/ProviderConfiguration.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using LaunchDarkly.Sdk.Server.Interfaces;
2+
using LaunchDarkly.Sdk.Server.Subsystems;
23

34
namespace LaunchDarkly.OpenFeature.ServerProvider
45
{
@@ -62,7 +63,7 @@ public static ProviderConfigurationBuilder Builder(ProviderConfiguration fromCon
6263
/// SDK components should not use this property directly; instead, the SDK client will use it to create a
6364
/// logger instance which will be in <see cref="LdClientContext"/>.
6465
/// </remarks>
65-
public ILoggingConfigurationFactory LoggingConfigurationFactory { get; }
66+
public IComponentConfigurer<LoggingConfiguration> LoggingConfigurationFactory { get; }
6667

6768
#region Internal constructor
6869

0 commit comments

Comments
 (0)