diff --git a/src/OpenFeature.Contrib.Providers.ConfigCat/ConfigCatProvider.cs b/src/OpenFeature.Contrib.Providers.ConfigCat/ConfigCatProvider.cs index 48f966ed..c3889f5f 100644 --- a/src/OpenFeature.Contrib.Providers.ConfigCat/ConfigCatProvider.cs +++ b/src/OpenFeature.Contrib.Providers.ConfigCat/ConfigCatProvider.cs @@ -29,6 +29,19 @@ public ConfigCatProvider(string sdkKey, Action configBui Client = ConfigCatClient.Get(sdkKey, configBuilder); } + /// + public override Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + return Client.WaitForReadyAsync(cancellationToken); + } + + /// + public override Task ShutdownAsync(CancellationToken cancellationToken = default) + { + Client.Dispose(); + return Task.CompletedTask; + } + /// public override Metadata GetMetadata() { diff --git a/src/OpenFeature.Contrib.Providers.ConfigCat/OpenFeature.Contrib.Providers.ConfigCat.csproj b/src/OpenFeature.Contrib.Providers.ConfigCat/OpenFeature.Contrib.Providers.ConfigCat.csproj index 590b2244..0094f252 100644 --- a/src/OpenFeature.Contrib.Providers.ConfigCat/OpenFeature.Contrib.Providers.ConfigCat.csproj +++ b/src/OpenFeature.Contrib.Providers.ConfigCat/OpenFeature.Contrib.Providers.ConfigCat.csproj @@ -16,6 +16,6 @@ - + diff --git a/src/OpenFeature.Contrib.Providers.ConfigCat/README.md b/src/OpenFeature.Contrib.Providers.ConfigCat/README.md index b08e8826..227f7680 100644 --- a/src/OpenFeature.Contrib.Providers.ConfigCat/README.md +++ b/src/OpenFeature.Contrib.Providers.ConfigCat/README.md @@ -2,7 +2,7 @@ The ConfigCat Flag provider allows you to connect to your ConfigCat instance. -# .Net SDK usage +# .NET SDK usage ## Requirements @@ -47,68 +47,72 @@ paket add OpenFeature.Contrib.Providers.ConfigCat The following example shows how to use the ConfigCat provider with the OpenFeature SDK. ```csharp -using OpenFeature.Contrib.Providers.ConfigCat; +using System; +using ConfigCat.Client; +using OpenFeature.Contrib.ConfigCat; -namespace OpenFeatureTestApp +var configCatProvider = new ConfigCatProvider("#YOUR-SDK-KEY#"); + +// Set the configCatProvider as the provider for the OpenFeature SDK +await OpenFeature.Api.Instance.SetProviderAsync(configCatProvider); + +var client = OpenFeature.Api.Instance.GetClient(); + +var isAwesomeFeatureEnabled = await client.GetBooleanValueAsync("isAwesomeFeatureEnabled", false); +if (isAwesomeFeatureEnabled) +{ + doTheNewThing(); +} +else { - class Hello { - static void Main(string[] args) { - var configCatProvider = new ConfigCatProvider("#YOUR-SDK-KEY#"); - - // Set the configCatProvider as the provider for the OpenFeature SDK - OpenFeature.Api.Instance.SetProvider(configCatProvider); - - var client = OpenFeature.Api.Instance.GetClient(); - - var val = client.GetBooleanValueAsync("isMyAwesomeFeatureEnabled", false); - - if(isMyAwesomeFeatureEnabled) - { - doTheNewThing(); - } - else - { - doTheOldThing(); - } - } - } + doTheOldThing(); } ``` ### Customizing the ConfigCat Provider -The ConfigCat provider can be customized by passing a `ConfigCatClientOptions` object to the constructor. +The ConfigCat provider can be customized by passing a callback setting up a `ConfigCatClientOptions` object to the constructor. ```csharp -var configCatOptions = new ConfigCatClientOptions +Action configureConfigCatOptions = (options) => { - PollingMode = PollingModes.ManualPoll; - Logger = new ConsoleLogger(LogLevel.Info); + options.PollingMode = PollingModes.LazyLoad(cacheTimeToLive: TimeSpan.FromSeconds(10)); + options.Logger = new ConsoleLogger(LogLevel.Info); + // ... }; -var configCatProvider = new ConfigCatProvider("#YOUR-SDK-KEY#", configCatOptions); +var configCatProvider = new ConfigCatProvider("#YOUR-SDK-KEY#", configureConfigCatOptions); ``` For a full list of options see the [ConfigCat documentation](https://configcat.com/docs/sdk-reference/dotnet/). -## EvaluationContext and ConfigCat User relationship +### Cleaning up + +On application shutdown, clean up the OpenFeature provider and the underlying ConfigCat client. + +```csharp +await OpenFeature.Api.Instance.ShutdownAsync(); +``` + +## EvaluationContext and ConfigCat User Object relationship -ConfigCat has the concept of Users where you can evaluate a flag based on properties. The OpenFeature SDK has the concept of an EvaluationContext which is a dictionary of string keys and values. The ConfigCat provider will map the EvaluationContext to a ConfigCat User. +An evaluation context in the OpenFeature specification is a container for arbitrary contextual data that can be used as a basis for feature flag evaluation. +The ConfigCat provider translates these evaluation contexts to ConfigCat [User Objects](https://configcat.com/docs/targeting/user-object/). -The ConfigCat User has a few pre-defined parameters that can be used to evaluate a flag. These are: +The ConfigCat User Object has a few pre-defined attributes that can be used to evaluate a flag. These are: -| Parameter | Description | -|-----------|---------------------------------------------------------------------------------------------------------------------------------| -| `Id` | *REQUIRED*. Unique identifier of a user in your application. Can be any `string` value, even an email address. | -| `Email` | Optional parameter for easier targeting rule definitions. | -| `Country` | Optional parameter for easier targeting rule definitions. | -| `Custom` | Optional dictionary for custom attributes of a user for advanced targeting rule definitions. E.g. User role, Subscription type. | +| Attribute | Description | +|--------------|----------------------------------------------------------------------------------------------------------------| +| `Identifier` | *REQUIRED*. Unique identifier of a user in your application. Can be any `string` value, even an email address. | +| `Email` | The email address of the user. | +| `Country` | The country of the user. | -Since EvaluationContext is a simple dictionary, the provider will try to match the keys to the ConfigCat User parameters following the table below in a case-insensitive manner. +Since `EvaluationContext` is a simple dictionary, the provider will try to match the keys to ConfigCat user attributes following the table below in a case-insensitive manner. -| EvaluationContext Key | ConfigCat User Parameter | +| EvaluationContext Key | ConfigCat User Attribute | |-----------------------|--------------------------| -| `id` | `Id` | -| `identifier` | `Id` | +| `id` | `Identifier` | +| `identifier` | `Identifier` | | `email` | `Email` | -| `country` | `Country` | \ No newline at end of file +| `country` | `Country` | +| Any other | `Custom` | \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.ConfigCat/UserBuilder.cs b/src/OpenFeature.Contrib.Providers.ConfigCat/UserBuilder.cs index ba8798ee..391e487b 100644 --- a/src/OpenFeature.Contrib.Providers.ConfigCat/UserBuilder.cs +++ b/src/OpenFeature.Contrib.Providers.ConfigCat/UserBuilder.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using ConfigCat.Client; using OpenFeature.Model; @@ -17,35 +16,32 @@ internal static User BuildUser(this EvaluationContext context) return null; } - var user = context.TryGetValuesInsensitive(PossibleUserIds, out var pair) - ? new User(pair.Value.AsString) - : new User(Guid.NewGuid().ToString()); + var user = new User(context.GetUserId()); foreach (var value in context) { - switch (value.Key.ToUpperInvariant()) + if (StringComparer.OrdinalIgnoreCase.Equals("EMAIL", value.Key)) { - case "EMAIL": - user.Email = value.Value.AsString; - continue; - case "COUNTRY": - user.Country = value.Value.AsString; - continue; - default: - user.Custom.Add(value.Key, value.Value.AsString); - continue; + user.Email = value.Value.AsString; + } + else if (StringComparer.OrdinalIgnoreCase.Equals("COUNTRY", value.Key)) + { + user.Country = value.Value.AsString; + } + else + { + user.Custom.Add(value.Key, value.Value.AsString); } } return user; } - private static bool TryGetValuesInsensitive(this EvaluationContext context, string[] keys, - out KeyValuePair pair) + private static string GetUserId(this EvaluationContext context) { - pair = context.AsDictionary().FirstOrDefault(x => keys.Contains(x.Key.ToUpperInvariant())); + var pair = context.AsDictionary().FirstOrDefault(x => PossibleUserIds.Contains(x.Key, StringComparer.OrdinalIgnoreCase)); - return pair.Key != null; + return pair.Key != null ? pair.Value.AsString : ""; } } } \ No newline at end of file diff --git a/test/OpenFeature.Contrib.Providers.ConfigCat.Test/ConfigCatProviderTest.cs b/test/OpenFeature.Contrib.Providers.ConfigCat.Test/ConfigCatProviderTest.cs index 1a478f03..0bd0b44f 100644 --- a/test/OpenFeature.Contrib.Providers.ConfigCat.Test/ConfigCatProviderTest.cs +++ b/test/OpenFeature.Contrib.Providers.ConfigCat.Test/ConfigCatProviderTest.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Net; +using System.Threading; using System.Threading.Tasks; using AutoFixture.Xunit2; using ConfigCat.Client; @@ -12,14 +14,58 @@ namespace OpenFeature.Contrib.ConfigCat.Test { public class ConfigCatProviderTest { + const string TestConfigJson = +@" +{ + ""f"": { + ""isAwesomeFeatureEnabled"": { + ""t"": 0, + ""v"": { + ""b"": true + } + }, + ""isPOCFeatureEnabled"": { + ""t"": 0, + ""r"": [ + { + ""c"": [ + { + ""u"": { + ""a"": ""Email"", + ""c"": 2, + ""l"": [ + ""@example.com"" + ] + } + } + ], + ""s"": { + ""v"": { + ""b"": true + } + } + } + ], + ""v"": { + ""b"": false + } + } + } +} +"; + [Theory] [AutoData] - public void CreateConfigCatProvider_WithSdkKey_CreatesProviderInstanceSuccessfully(string sdkKey) + public async void CreateConfigCatProvider_WithSdkKey_CreatesProviderInstanceSuccessfully(string sdkKey) { var configCatProvider = new ConfigCatProvider(sdkKey, options => { options.FlagOverrides = BuildFlagOverrides(); }); + await configCatProvider.InitializeAsync(EvaluationContext.Empty); + Assert.NotNull(configCatProvider.Client); + + await configCatProvider.ShutdownAsync(); } [Theory] @@ -93,11 +139,40 @@ public async Task GetStructureValueAsync_ForFeature_ReturnExpectedResult(string var configCatProvider = new ConfigCatProvider(sdkKey, options => { options.FlagOverrides = BuildFlagOverrides(("example-feature", defaultValue.AsString)); }); + await configCatProvider.InitializeAsync(EvaluationContext.Empty); + var result = await configCatProvider.ResolveStructureValueAsync("example-feature", defaultValue); Assert.Equal(defaultValue.AsString, result.Value.AsString); Assert.Equal("example-feature", result.FlagKey); Assert.Equal(ErrorType.None, result.ErrorType); + + await configCatProvider.ShutdownAsync(); + } + + [Theory] + [InlineAutoData("alice@configcat.com", false)] + [InlineAutoData("bob@example.com", true)] + public async Task OpenFeatureAPI_EndToEnd_Test(string email, bool expectedValue) + { + var configCatProvider = new ConfigCatProvider("fake-67890123456789012/1234567890123456789012", options => + { options.ConfigFetcher = new FakeConfigFetcher(TestConfigJson); }); + + await OpenFeature.Api.Instance.SetProviderAsync(configCatProvider); + + var client = OpenFeature.Api.Instance.GetClient(); + + var evaluationContext = EvaluationContext.Builder() + .Set("email", email) + .Build(); + + var result = await client.GetBooleanDetailsAsync("isPOCFeatureEnabled", false, evaluationContext); + + Assert.Equal(expectedValue, result.Value); + Assert.Equal("isPOCFeatureEnabled", result.FlagKey); + Assert.Equal(ErrorType.None, result.ErrorType); + + await OpenFeature.Api.Instance.ShutdownAsync(); } private static async Task ExecuteResolveTest(object value, T defaultValue, T expectedValue, string sdkKey, Func>> resolveFunc) @@ -105,11 +180,15 @@ private static async Task ExecuteResolveTest(object value, T defaultValue, T var configCatProvider = new ConfigCatProvider(sdkKey, options => { options.FlagOverrides = BuildFlagOverrides(("example-feature", value)); }); + await configCatProvider.InitializeAsync(EvaluationContext.Empty); + var result = await resolveFunc(configCatProvider, "example-feature", defaultValue); Assert.Equal(expectedValue, result.Value); Assert.Equal("example-feature", result.FlagKey); Assert.Equal(ErrorType.None, result.ErrorType); + + await configCatProvider.ShutdownAsync(); } private static async Task ExecuteResolveErrorTest(object value, T defaultValue, ErrorType expectedErrorType, string sdkKey, Func>> resolveFunc) @@ -117,9 +196,13 @@ private static async Task ExecuteResolveErrorTest(object value, T defaultValu var configCatProvider = new ConfigCatProvider(sdkKey, options => { options.FlagOverrides = BuildFlagOverrides(("example-feature", value)); }); + await configCatProvider.InitializeAsync(EvaluationContext.Empty); + var exception = await Assert.ThrowsAsync(() => resolveFunc(configCatProvider, "example-feature", defaultValue)); Assert.Equal(expectedErrorType, exception.ErrorType); + + await configCatProvider.ShutdownAsync(); } private static FlagOverrides BuildFlagOverrides(params (string key, object value)[] values) @@ -132,5 +215,22 @@ private static FlagOverrides BuildFlagOverrides(params (string key, object value return FlagOverrides.LocalDictionary(dictionary, OverrideBehaviour.LocalOnly); } + + private sealed class FakeConfigFetcher : IConfigCatConfigFetcher + { + private readonly string configJson; + + public FakeConfigFetcher(string configJson) + { + this.configJson = configJson; + } + + public void Dispose() { } + + public Task FetchAsync(FetchRequest request, CancellationToken cancellationToken) + { + return Task.FromResult(new FetchResponse(HttpStatusCode.OK, reasonPhrase: null, headers: Array.Empty>(), this.configJson)); + } + } } } \ No newline at end of file