From 66f1bd8ae9ba832ad164a04e07f557d41f70214f Mon Sep 17 00:00:00 2001 From: Ben Papillon Date: Mon, 10 Feb 2025 16:52:31 -0500 Subject: [PATCH] feat: Adding OpenFeature provider for Schematic Signed-off-by: Ben Papillon --- DotnetSdkContrib.sln | 14 ++ release-please-config.json | 10 + ...Feature.Contrib.Providers.Schematic.csproj | 29 +++ .../README.md | 124 +++++++++++ .../SchematicProvider.cs | 210 ++++++++++++++++++ .../ValueExtensions.cs | 48 ++++ .../version.txt | 1 + ...re.Contrib.Providers.Schematic.Test.csproj | 11 + .../SchematicProviderTest.cs | 159 +++++++++++++ 9 files changed, 606 insertions(+) create mode 100644 src/OpenFeature.Contrib.Providers.Schematic/OpenFeature.Contrib.Providers.Schematic.csproj create mode 100644 src/OpenFeature.Contrib.Providers.Schematic/README.md create mode 100644 src/OpenFeature.Contrib.Providers.Schematic/SchematicProvider.cs create mode 100644 src/OpenFeature.Contrib.Providers.Schematic/ValueExtensions.cs create mode 100644 src/OpenFeature.Contrib.Providers.Schematic/version.txt create mode 100644 test/OpenFeature.Contrib.Providers.Schematic.Test/OpenFeature.Contrib.Providers.Schematic.Test.csproj create mode 100644 test/OpenFeature.Contrib.Providers.Schematic.Test/SchematicProviderTest.cs diff --git a/DotnetSdkContrib.sln b/DotnetSdkContrib.sln index 57004386..e1ab21da 100644 --- a/DotnetSdkContrib.sln +++ b/DotnetSdkContrib.sln @@ -45,6 +45,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Provide EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Flipt.Test", "test\OpenFeature.Contrib.Providers.Flipt.Test\OpenFeature.Contrib.Providers.Flipt.Test.csproj", "{B446D481-B5A3-4509-8933-C4CF6DA9B147}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Schematic", "src\OpenFeature.Contrib.Providers.Schematic\OpenFeature.Contrib.Providers.Schematic.csproj", "{CF1AB517-1D51-455F-80C0-56B4856E6A6B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Schematic.Test", "test\OpenFeature.Contrib.Providers.Schematic.Test\OpenFeature.Contrib.Providers.Schematic.Test.csproj", "{08BD26A8-0C14-40F1-BFAF-7D413B76EF6B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -127,6 +131,14 @@ Global {B446D481-B5A3-4509-8933-C4CF6DA9B147}.Debug|Any CPU.Build.0 = Debug|Any CPU {B446D481-B5A3-4509-8933-C4CF6DA9B147}.Release|Any CPU.ActiveCfg = Release|Any CPU {B446D481-B5A3-4509-8933-C4CF6DA9B147}.Release|Any CPU.Build.0 = Release|Any CPU + {CF1AB517-1D51-455F-80C0-56B4856E6A6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF1AB517-1D51-455F-80C0-56B4856E6A6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF1AB517-1D51-455F-80C0-56B4856E6A6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF1AB517-1D51-455F-80C0-56B4856E6A6B}.Release|Any CPU.Build.0 = Release|Any CPU + {08BD26A8-0C14-40F1-BFAF-7D413B76EF6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08BD26A8-0C14-40F1-BFAF-7D413B76EF6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08BD26A8-0C14-40F1-BFAF-7D413B76EF6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08BD26A8-0C14-40F1-BFAF-7D413B76EF6B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -151,5 +163,7 @@ Global {F3080350-B0AB-4D59-B416-50CC38C99087} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE} {5ECF7DBF-FE64-40A2-BF39-239DE173DA4B} = {0E563821-BD08-4B7F-BF9D-395CAD80F026} {B446D481-B5A3-4509-8933-C4CF6DA9B147} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE} + {CF1AB517-1D51-455F-80C0-56B4856E6A6B} = {0E563821-BD08-4B7F-BF9D-395CAD80F026} + {08BD26A8-0C14-40F1-BFAF-7D413B76EF6B} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE} EndGlobalSection EndGlobal diff --git a/release-please-config.json b/release-please-config.json index cabbd73f..87580707 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -82,6 +82,16 @@ "extra-files": [ "OpenFeature.Contrib.Providers.Flipt.csproj" ] + }, + "src/OpenFeature.Contrib.Providers.Schematic": { + "package-name": "OpenFeature.Contrib.Providers.Schematic", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "OpenFeature.Contrib.Providers.Schematic.csproj" + ] } }, "changelog-sections": [ diff --git a/src/OpenFeature.Contrib.Providers.Schematic/OpenFeature.Contrib.Providers.Schematic.csproj b/src/OpenFeature.Contrib.Providers.Schematic/OpenFeature.Contrib.Providers.Schematic.csproj new file mode 100644 index 00000000..23274ae1 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Schematic/OpenFeature.Contrib.Providers.Schematic.csproj @@ -0,0 +1,29 @@ + + + + OpenFeature.Contrib.Providers.Schematic + 0.1.0 + $(VersionNumber) + $(VersionNumber) + $(VersionNumber) + Schematic provider for .NET + README.md + Benjamin Papillon + + + + + <_Parameter1>$(MSBuildProjectName).Test + + + + + + + + + + + + + diff --git a/src/OpenFeature.Contrib.Providers.Schematic/README.md b/src/OpenFeature.Contrib.Providers.Schematic/README.md new file mode 100644 index 00000000..1b1e3c07 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Schematic/README.md @@ -0,0 +1,124 @@ +# Schematic .NET Provider + +The Schematic provider allows you to connect to your Schematic instance through the OpenFeature SDK + +# .Net SDK usage + +## Requirements + +- open-feature/dotnet-sdk v1.5.0 > v2.0.0 + +## Install dependencies + +The first things we will do is install the **Open Feature SDK** and the **Schematic OpenFeature provider**. + +### .NET Cli +```shell +dotnet add package OpenFeature.Contrib.Providers.Schematic +``` +### Package Manager + +```shell +NuGet\Install-Package OpenFeature.Contrib.Providers.Schematic +``` +### Package Reference + +```xml + +``` +### Paket cli + +```shell +paket add OpenFeature.Contrib.Providers.Schematic +``` + +### Cake + +```shell +// Install OpenFeature.Contrib.Providers.Schematic as a Cake Addin +#addin nuget:?package=OpenFeature.Contrib.Providers.Schematic + +// Install OpenFeature.Contrib.Providers.Schematic as a Cake Tool +#tool nuget:?package=OpenFeature.Contrib.Providers.Schematic +``` + +## Using the Schematic Provider with the OpenFeature SDK + +To use Schematic as an OpenFeature provider, define your provider and Schematic settings. + +```csharp +using OpenFeature; +using OpenFeature.Contrib.Providers.Schematic; +using System; + +var schematicProvider = new SchematicFeatureProvider("your-api-key"); + +// Set the schematicProvider as the provider for the OpenFeature SDK +await OpenFeature.Api.Instance.SetProviderAsync(schematicProvider); + +// Get an OpenFeature client +var client = OpenFeature.Api.Instance.GetClient("my-app"); + +// Set company and/or user context +var context = EvaluationContext.Builder() + .Set("company", new Dictionary { + { "id", "your-company-id" }, + }) + .Set("user", new Dictionary { + { "id", "your-user-id" }, + }) + .Build(); + +// Evaluate a flag +var val = await client.GetBooleanValueAsync("your-flag-key", false, context); + +// Print the value of the 'your-flag-key' feature flag +Console.WriteLine(val); +``` + +You can also provide additional configuration options to the provider to manage caching behavior, offline mode, and other capabilities: + +```csharp +using OpenFeature; +using OpenFeature.Contrib.Providers.Schematic; +using SchematicHQ.Client; +using System; + +var options = new ClientOptions +{ + Offline = true, // Run in offline mode + FlagDefaults = new Dictionary // Default values for offline mode + { + { "some-flag-key", true } + }, + Logger = new ConsoleLogger(), // Optional custom logger + CacheProviders = new List> // Optional cache configuration + { + new LocalCache(1000, TimeSpan.FromSeconds(30)) + } +}; + +var schematicProvider = new SchematicFeatureProvider("your-api-key", options); + +// Set the schematicProvider as the provider for the OpenFeature SDK +await OpenFeature.Api.Instance.SetProviderAsync(schematicProvider); + +// Get an OpenFeature client +var client = OpenFeature.Api.Instance.GetClient("my-app"); + +// Set company and/or user context +var context = EvaluationContext.Builder() + .Set("company", new Dictionary { + { "id", "your-company-id" }, + }) + .Set("user", new Dictionary { + { "id", "your-user-id" }, + }) + .Build(); + +// Evaluate a flag +var val = await client.GetBooleanValueAsync("your-flag-key", false, context); + +// Print the value of the 'your-flag-key' feature flag +Console.WriteLine(val); +``` diff --git a/src/OpenFeature.Contrib.Providers.Schematic/SchematicProvider.cs b/src/OpenFeature.Contrib.Providers.Schematic/SchematicProvider.cs new file mode 100644 index 00000000..21727e4e --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Schematic/SchematicProvider.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using OpenFeature; +using OpenFeature.Model; +using OpenFeature.Constant; +using SchematicHQ.Client; + +namespace OpenFeature.Contrib.Providers.Schematic +{ + public class SchematicFeatureProvider : FeatureProvider + { + private readonly SchematicHQ.Client.Schematic _schematic; + private readonly ISchematicLogger _logger; + + public SchematicFeatureProvider(string apiKey, ClientOptions options) + { + if (options == null) + { + options = new ClientOptions(); + } + _logger = options.Logger ?? new ConsoleLogger(); + _schematic = new SchematicHQ.Client.Schematic(apiKey, options); + } + + public override Metadata GetMetadata() + { + return new Metadata("schematic-provider"); + } + + public override async Task> ResolveBooleanValueAsync( + string flagKey, + bool defaultValue, + EvaluationContext context, + CancellationToken cancellationToken) + { + _logger.Debug("evaluating boolean flag: {0}", flagKey); + + Dictionary company = null; + Dictionary user = null; + + if (context != null) + { + if (companyValue != null) + { + try + { + company = companyValue.ToObject>(); + } + catch (Exception) + { + _logger.Debug("error converting company to dictionary"); + } + } + var userValue = context.TryGetValue("user", out var userVal) ? userVal : null; + if (userValue != null) + { + try + { + user = userValue.ToObject>(); + } + catch (Exception) + { + _logger.Debug("error converting user to dictionary"); + } + } + } + + try + { + bool value = await _schematic.CheckFlag(flagKey, company, user); + _logger.Debug("evaluated flag: {0} => {1}", flagKey, value); + return new ResolutionDetails( + flagKey: flagKey, + value: value, + variant: value ? "on" : "off", + reason: "schematic evaluation" + ); + } + catch (Exception ex) + { + _logger.Debug("error evaluating flag {0}: {1}. using default {2}", flagKey, ex.Message, defaultValue); + return new ResolutionDetails( + flagKey: flagKey, + value: defaultValue, + variant: defaultValue ? "on" : "off", + errorType: ErrorType.General, + reason: "error", + errorMessage: ex.Message + ); + } + } + + public override Task> ResolveStringValueAsync( + string flagKey, + string defaultValue, + EvaluationContext context, + CancellationToken cancellationToken) + { + return Task.FromResult(new ResolutionDetails( + flagKey: flagKey, + value: defaultValue, + reason: "unsupported type" + )); + } + + public override Task> ResolveIntegerValueAsync( + string flagKey, + int defaultValue, + EvaluationContext context, + CancellationToken cancellationToken) + { + return Task.FromResult(new ResolutionDetails( + flagKey: flagKey, + value: defaultValue, + variant: defaultValue.ToString(), + reason: "unsupported type" + )); + } + + public override Task> ResolveDoubleValueAsync( + string flagKey, + double defaultValue, + EvaluationContext context, + CancellationToken cancellationToken) + { + return Task.FromResult(new ResolutionDetails( + flagKey: flagKey, + value: defaultValue, + variant: defaultValue.ToString(), + reason: "unsupported type" + )); + } + + public override Task> ResolveStructureValueAsync( + string flagKey, + Value defaultValue, + EvaluationContext context, + CancellationToken cancellationToken) + { + return Task.FromResult(new ResolutionDetails( + flagKey: flagKey, + value: defaultValue, + variant: defaultValue?.ToString() ?? string.Empty, + reason: "unsupported type" + )); + } + + public override Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken) + { + _logger.Debug("initializing schematic provider"); + return Task.CompletedTask; + } + + public override async Task ShutdownAsync(CancellationToken cancellationToken) + { + _logger.Debug("shutting down schematic provider"); + await _schematic.Shutdown(); + } + + public Task TrackEventAsync(string eventName, EvaluationContext context, CancellationToken cancellationToken) + { + _logger.Debug("tracking event: {0}", eventName); + + Dictionary company = null; + Dictionary user = null; + Dictionary traits = null; + + if (context != null) + { + var companyValue = context.GetValue("company"); + if (companyValue != null) + { + try + { + company = companyValue.ToObject>(); + } + catch (Exception) + { + } + } + var userValue = context.GetValue("user"); + if (userValue != null) + { + try + { + user = userValue.ToObject>(); + } + catch (Exception) + { + } + } + var traitsValue = context.GetValue("traits"); + if (traitsValue != null) + { + try + { + traits = traitsValue.ToObject>(); + } + catch (Exception) + { + } + } + } + _schematic.Track(eventName, company, user, traits); + return Task.CompletedTask; + } + } +} diff --git a/src/OpenFeature.Contrib.Providers.Schematic/ValueExtensions.cs b/src/OpenFeature.Contrib.Providers.Schematic/ValueExtensions.cs new file mode 100644 index 00000000..40d7d40c --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Schematic/ValueExtensions.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using OpenFeature.Model; + +namespace OpenFeature.Contrib.Providers.Schematic +{ + internal static class ValueExtensions + { + public static T ToObject(this Value value) where T : class + { + if (value == null || value.IsNull) return null; + + if (value.IsStructure) + { + var structure = value.AsStructure; + if (structure == null) return null; + + if (typeof(T) == typeof(Dictionary)) + { + var dict = new Dictionary(); + foreach (var kvp in structure) + { + var stringValue = kvp.Value?.AsString; + if (stringValue != null) + { + dict[kvp.Key] = stringValue; + } + } + return dict as T; + } + else if (typeof(T) == typeof(Dictionary)) + { + var dict = new Dictionary(); + foreach (var kvp in structure) + { + var objValue = kvp.Value?.AsObject; + if (objValue != null) + { + dict[kvp.Key] = objValue; + } + } + return dict as T; + } + } + return null; + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.Schematic/version.txt b/src/OpenFeature.Contrib.Providers.Schematic/version.txt new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Schematic/version.txt @@ -0,0 +1 @@ +0.1.0 diff --git a/test/OpenFeature.Contrib.Providers.Schematic.Test/OpenFeature.Contrib.Providers.Schematic.Test.csproj b/test/OpenFeature.Contrib.Providers.Schematic.Test/OpenFeature.Contrib.Providers.Schematic.Test.csproj new file mode 100644 index 00000000..c44dd092 --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.Schematic.Test/OpenFeature.Contrib.Providers.Schematic.Test.csproj @@ -0,0 +1,11 @@ + + + 7.3 + net6.0;net8.0 + + + + + + + diff --git a/test/OpenFeature.Contrib.Providers.Schematic.Test/SchematicProviderTest.cs b/test/OpenFeature.Contrib.Providers.Schematic.Test/SchematicProviderTest.cs new file mode 100644 index 00000000..5c06141d --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.Schematic.Test/SchematicProviderTest.cs @@ -0,0 +1,159 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using OpenFeature; +using OpenFeature.Model; +using SchematicHQ.Client; + +namespace OpenFeature.Contrib.Providers.Schematic.Tests +{ + public class SchematicProviderTests + { + private SchematicFeatureProvider CreateProvider(ClientOptions options = null) + { + if (options == null) + { + options = new ClientOptions { Offline = true }; + } + return new SchematicFeatureProvider("dummy-api-key", options); + } + + [Fact] + public void GetMetadata_Returns_Correct_Name() + { + var provider = CreateProvider(); + var metadata = provider.GetMetadata(); + Assert.NotNull(metadata); + Assert.Equal("schematic-provider", metadata.Name); + } + + [Fact] + public async Task ResolveBoolean_Returns_Correct_Value_From_FlagDefaults() + { + var options = new ClientOptions + { + Offline = true, + FlagDefaults = new Dictionary + { + { "test_flag", true } + } + }; + + var provider = CreateProvider(options); + var result = await provider.ResolveBooleanValueAsync( + "test_flag", + false, + EvaluationContext.Empty, + CancellationToken.None); + Assert.True(result.Value); + Assert.Equal("on", result.Variant); + Assert.Equal("schematic evaluation", result.Reason); + } + + [Fact] + public async Task ResolveBoolean_Returns_Default_When_Flag_Not_Set() + { + var options = new ClientOptions { Offline = true }; + var provider = CreateProvider(options); + var result = await provider.ResolveBooleanValueAsync( + "nonexistent_flag", + true, + EvaluationContext.Empty, + CancellationToken.None); + Assert.False(result.Value); + Assert.Equal("off", result.Variant); + Assert.Equal("schematic evaluation", result.Reason); + } + + [Fact] + public async Task ResolveString_Returns_Unsupported_Type() + { + var provider = CreateProvider(); + var result = await provider.ResolveStringValueAsync( + "string_flag", + "default", + EvaluationContext.Empty, + CancellationToken.None); + Assert.Equal("default", result.Value); + Assert.Equal("unsupported type", result.Reason); + } + + [Fact] + public async Task ResolveInteger_Returns_Unsupported_Type() + { + var provider = CreateProvider(); + var result = await provider.ResolveIntegerValueAsync( + "int_flag", + 42, + EvaluationContext.Empty, + CancellationToken.None); + Assert.Equal(42, result.Value); + Assert.Equal("unsupported type", result.Reason); + } + + [Fact] + public async Task ResolveDouble_Returns_Unsupported_Type() + { + var provider = CreateProvider(); + var result = await provider.ResolveDoubleValueAsync( + "double_flag", + 3.14, + EvaluationContext.Empty, + CancellationToken.None); + Assert.Equal(3.14, result.Value); + Assert.Equal("unsupported type", result.Reason); + } + + [Fact] + public async Task ResolveStructure_Returns_Unsupported_Type() + { + var structure = Structure.Builder() + .Set("key", new Value("value")) + .Build(); + var defaultValue = new Value(structure); + + var provider = CreateProvider(); + var result = await provider.ResolveStructureValueAsync( + "object_flag", + defaultValue, + EvaluationContext.Empty, + CancellationToken.None); + Assert.Equal(defaultValue, result.Value); + Assert.Equal("unsupported type", result.Reason); + } + + [Fact] + public async Task TrackEvent_Completes_Without_Error() + { + var provider = CreateProvider(); + + var companyStructure = Structure.Builder() + .Set("name", new Value("test_company")) + .Build(); + + var userStructure = Structure.Builder() + .Set("id", new Value("test_user")) + .Build(); + + var traitsStructure = Structure.Builder() + .Set("score", new Value(100)) + .Build(); + + var context = EvaluationContext.Builder() + .Set("company", new Value(companyStructure)) + .Set("user", new Value(userStructure)) + .Set("traits", new Value(traitsStructure)) + .Build(); + + await provider.TrackEventAsync("test_event", context, CancellationToken.None); + } + + [Fact] + public async Task Shutdown_Completes_Without_Error() + { + var provider = CreateProvider(); + await provider.ShutdownAsync(CancellationToken.None); + } + } +}