Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
92 changes: 89 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,97 @@ For information on using the OpenFeature client please refer to the [OpenFeature

## OpenFeature Specific Considerations

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.
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`.

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.
There are 4 different scenarios related to the `kind`:
1. There is no `kind` attribute. In this case the provider will treat the context as a single context containing a "user" kind.
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.
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.
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.

If a top level `custom` attribute is defined on the `EvaluationContext`, then that will be a `custom` attribute inside `custom` for a `User`.
The `kind` attribute should be a string containing only contain ASCII letters, numbers, `.`, `_` or `-`.

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.

There are several other attributes which have special functionality within a single or multi-context.
- 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___)
- 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)
- 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_)

### Examples

#### A single user context

```csharp
var evaluationContext = EvaluationContext.Builder()
.Set("targetingKey", "my-user-key") // Could also use "key" instead of "targetingKey".
.Build();
```

#### A single context of kind "organization"

```csharp
var evaluationContext = EvaluationContext.Builder()
.Set("kind", "organization")
.Set("targetingKey", "my-org-key") // Could also use "key" instead of "targetingKey".
.Build();
```

#### A multi-context containing a "user" and an "organization"

```csharp
var evaluationContext = EvaluationContext.Builder()
.Set("kind", "multi") // Lets the provider know this is a multi-context
// Every other top level attribute should be a structure representing
// individual contexts of the multi-context.
// (non-conforming attributes will be ignored and a warning logged).
.Set("organization", new Structure(new Dictionary<string, Value>
{
{"targetingKey", new Value("my-org-key")},
{"name", new Value("the-org-name")},
{"myCustomAttribute", new Value("myAttributeValue")}
}))
.Set("user", new Structure(new Dictionary<string, Value> {
{"targetingKey", new Value("my-user-key")},
}))
.Build();
```

#### Setting private attributes in a single context

```csharp
var evaluationContext = EvaluationContext.Builder()
.Set("kind", "organization")
.Set("name", "the-org-name")
.Set("targetingKey", "my-org-key")
.Set("anonymous", true)
.Set("myCustomAttribute", "myCustomValue")
.Set("privateAttributes", new Value(new List<Value>{new Value("myCustomAttribute")}))
.Build();
```

#### Setting private attributes in a multi-context

```csharp
var evaluationContext = EvaluationContext.Builder()
.Set("kind", "multi")
.Set("organization", new Structure(new Dictionary<string, Value>
{
{"targetingKey", new Value("my-org-key")},
{"name", new Value("the-org-name")},
// This will ONLY apply to the "organization" attributes.
{"privateAttributes", new Value(new List<Value>{new Value("myCustomAttribute")})}
// This attribute will be private.
{"myCustomAttribute", new Value("myAttributeValue")},
}))
.Set("user", new Structure(new Dictionary<string, Value> {
{"targetingKey", new Value("my-user-key")},
{"anonymous", new Value(true)},
// This attribute will not be private.
{"myCustomAttribute", new Value("myAttributeValue")},
}))
.Build();
```

## Learn more

Expand Down
126 changes: 87 additions & 39 deletions src/LaunchDarkly.OpenFeature.ServerProvider/EvalContextConverter.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using LaunchDarkly.Logging;
using LaunchDarkly.Sdk;
using OpenFeature.Model;

namespace LaunchDarkly.OpenFeature.ServerProvider
{
/// <summary>
/// Class which converts <see cref="EvaluationContext"/> objects into <see cref="User"/> objects.
/// Class which converts <see cref="EvaluationContext"/> objects into <see cref="Context"/> objects.
/// </summary>
internal class EvalContextConverter
{
Expand Down Expand Up @@ -39,7 +42,7 @@ private static string InvalidTypeMessage(string attribute, string type) => $"The
/// A method to call with the extracted value.
/// This will only be called if the type was correct.
/// </param>
private void Extract(string key, LdValue value, Func<string, IUserBuilder> setter)
private void Extract(string key, LdValue value, Func<string, ContextBuilder> setter)
{
if (value.IsNull)
{
Expand All @@ -65,7 +68,7 @@ private void Extract(string key, LdValue value, Func<string, IUserBuilder> sette
/// A method to call with the extracted value.
/// This will only be called if the type was correct.
/// </param>
private void Extract(string key, LdValue value, Func<bool, IUserBuilder> setter)
private void Extract(string key, LdValue value, Func<bool, ContextBuilder> setter)
{
if (value.IsNull)
{
Expand All @@ -83,12 +86,12 @@ private void Extract(string key, LdValue value, Func<bool, IUserBuilder> setter)
}

/// <summary>
/// Extract a value and add it to a user builder.
/// Extract a value and add it to a context builder.
/// </summary>
/// <param name="key">The key to add to the user if the value can be extracted</param>
/// <param name="key">The key to add to the context if the value can be extracted</param>
/// <param name="value">The value to extract</param>
/// <param name="builder">The user builder to add the value to</param>
private void ProcessValue(string key, Value value, IUserBuilder builder)
/// <param name="builder">The context builder to add the value to</param>
private void ProcessValue(string key, Value value, ContextBuilder builder)
{
var ldValue = value.ToLdValue();

Expand All @@ -98,50 +101,95 @@ private void ProcessValue(string key, Value value, IUserBuilder builder)
case "targetingKey":
case "key":
break;
case "secondary":
Extract(key, ldValue, builder.Secondary);
break;
case "name":
Extract(key, ldValue, builder.Name);
break;
case "firstName":
Extract(key, ldValue, builder.FirstName);
break;
case "lastName":
Extract(key, ldValue, builder.LastName);
break;
case "email":
Extract(key, ldValue, builder.Email);
break;
case "avatar":
Extract(key, ldValue, builder.Avatar);
break;
case "ip":
Extract(key, ldValue, builder.IPAddress);
break;
case "country":
Extract(key, ldValue, builder.Country);
break;
case "anonymous":
Extract(key, ldValue, builder.Anonymous);
break;
case "privateAttributes":
builder.Private(ldValue.AsList(LdValue.Convert.String).ToArray());
break;
default:
// Was not a built-in attribute.
builder.Custom(key, ldValue);
builder.Set(key, ldValue);
break;
}
}

/// <summary>
/// Convert an <see cref="EvaluationContext"/> into a <see cref="User"/>.
/// Convert an <see cref="EvaluationContext"/> into a <see cref="Context"/>.
/// </summary>
/// <param name="evaluationContext">The evaluation context to convert</param>
/// <returns>A converted context</returns>
public Context ToLdContext(EvaluationContext evaluationContext)
{
// Use the kind to determine the evaluation context shape.
// If there is no kind at all, then we make a single context of "user" kind.
evaluationContext.TryGetValue("kind", out var kind);

var kindString = "user";
// A multi-context.
if (kind != null && kind.AsString == "multi")
{
return BuildMultiLdContext(evaluationContext);
}
// Single context with specified kind.
else if (kind != null && kind.IsString)
{
kindString = kind.AsString;
}
// The kind was not a string.
else if (kind != null && !kind.IsString)
{
_log.Warn("The EvaluationContext contained an invalid kind and it will be discarded.");
}
// Else, there is no kind, so we are going to assume a user.

return BuildSingleLdContext(evaluationContext.AsDictionary(), kindString);
}

/// <summary>
/// Convert an evaluation context into a multi-context.
/// </summary>
/// <param name="evaluationContext">The evaluation context to convert</param>
/// <returns>A converted user</returns>
public User ToLdUser(EvaluationContext evaluationContext)
/// <returns>A converted multi-context</returns>
private Context BuildMultiLdContext(EvaluationContext evaluationContext)
{
var multiBuilder = Context.MultiBuilder();
foreach (var pair in evaluationContext.AsDictionary())
{
// Don't need to inspect the "kind" key.
if (pair.Key == "kind") continue;

var kind = pair.Key;
var attributes = pair.Value;

if (!attributes.IsStructure)
{
_log.Warn("Top level attributes in a multi-kind context should be Structure types.");
continue;
}

multiBuilder.Add(BuildSingleLdContext(attributes.AsStructure.AsDictionary(), kind));
}


return multiBuilder.Build();
}

/// <summary>
/// Construct a single context from an immutable dictionary of attributes.
/// This can either be the entirety of a single context, or a part of a multi-context.
/// </summary>
/// <param name="attributes">The attributes to use when building the context</param>
/// <param name="kindString">The kind of the built context</param>
/// <returns>A converted context</returns>
private Context BuildSingleLdContext(IImmutableDictionary<string, Value> attributes, string kindString)
{
// targetingKey is the specification, so it takes precedence.
evaluationContext.TryGetValue("key", out var keyAttr);
evaluationContext.TryGetValue("targetingKey", out var targetingKey);
// targetingKey is in the specification, so it takes precedence.
attributes.TryGetValue("key", out var keyAttr);
attributes.TryGetValue("targetingKey", out var targetingKey);
var finalKey = (targetingKey ?? keyAttr)?.AsString;

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

var userBuilder = User.Builder(finalKey);
foreach (var kvp in evaluationContext)
var contextBuilder = Context.Builder(ContextKind.Of(kindString), finalKey);
foreach (var kvp in attributes)
{
var key = kvp.Key;
var value = kvp.Value;

ProcessValue(key, value, userBuilder);
ProcessValue(key, value, contextBuilder);
}

return userBuilder.Build();
return contextBuilder.Build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="LaunchDarkly.ServerSdk" Version="[6.3.2,7.0)" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="[7.0,8.0)" />
Copy link
Member Author

Choose a reason for hiding this comment

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

Should work with major versions until 8, which it should not work with.

<PackageReference Include="OpenFeature" Version="[1.0.0, 2.0.0)" />
</ItemGroup>

Expand Down
13 changes: 7 additions & 6 deletions src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using LaunchDarkly.Sdk;
using LaunchDarkly.Sdk.Server;
using LaunchDarkly.Sdk.Server.Interfaces;
using LaunchDarkly.Sdk.Server.Subsystems;
using OpenFeature;
using OpenFeature.Model;

Expand Down Expand Up @@ -39,7 +40,7 @@ public Provider(ILdClient client, ProviderConfiguration config = null)
{
_client = client;
var logConfig = (config?.LoggingConfigurationFactory ?? Components.Logging())
.CreateLoggingConfiguration();
.Build(null);
Copy link
Member Author

Choose a reason for hiding this comment

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

This is extremely questionable. I've not determined if there is anything else I can really do about it.


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

/// <inheritdoc />
public override Task<ResolutionDetails<string>> ResolveStringValue(string flagKey, string defaultValue,
EvaluationContext context = null) => Task.FromResult(_client
.StringVariationDetail(flagKey, _contextConverter.ToLdUser(context), defaultValue)
.StringVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
.ToResolutionDetails(flagKey));

/// <inheritdoc />
public override Task<ResolutionDetails<int>> ResolveIntegerValue(string flagKey, int defaultValue,
EvaluationContext context = null) => Task.FromResult(_client
.IntVariationDetail(flagKey, _contextConverter.ToLdUser(context), defaultValue)
.IntVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
.ToResolutionDetails(flagKey));

/// <inheritdoc />
public override Task<ResolutionDetails<double>> ResolveDoubleValue(string flagKey, double defaultValue,
EvaluationContext context = null) => Task.FromResult(_client
.DoubleVariationDetail(flagKey, _contextConverter.ToLdUser(context), defaultValue)
.DoubleVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
.ToResolutionDetails(flagKey));

/// <inheritdoc />
public override Task<ResolutionDetails<Value>> ResolveStructureValue(string flagKey, Value defaultValue,
EvaluationContext context = null) => Task.FromResult(_client
.JsonVariationDetail(flagKey, _contextConverter.ToLdUser(context), LdValue.Null)
.JsonVariationDetail(flagKey, _contextConverter.ToLdContext(context), LdValue.Null)
.ToValueDetail(defaultValue).ToResolutionDetails(flagKey));

#endregion
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using LaunchDarkly.Sdk.Server.Interfaces;
using LaunchDarkly.Sdk.Server.Subsystems;

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

#region Internal constructor

Expand Down
Loading