diff --git a/.github/component_owners.yml b/.github/component_owners.yml index cae64618..4fdbf288 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -22,6 +22,9 @@ components: src/OpenFeature.Contrib.Providers.Statsig: - jenshenneberg - lattenborough + src/OpenFeature.Contrib.Providers.Flipt: + - jeandreidc + - markphelps # test/ test/OpenFeature.Contrib.Hooks.Otel.Test: @@ -45,6 +48,9 @@ components: test/src/OpenFeature.Contrib.Providers.Statsig.Test: - jenshenneberg - lattenborough + test/src/OpenFeature.Contrib.Providers.Flipt.Test: + - jeandreidc + - markphelps ignored-authors: - renovate-bot diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 42060071..60e3503a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -5,5 +5,6 @@ "src/OpenFeature.Contrib.Providers.Flagsmith": "0.2.0", "src/OpenFeature.Contrib.Providers.ConfigCat": "0.1.1", "src/OpenFeature.Contrib.Providers.FeatureManagement": "0.1.0", - "src/OpenFeature.Contrib.Providers.Statsig": "0.1.0" + "src/OpenFeature.Contrib.Providers.Statsig": "0.1.0", + "src/OpenFeature.Contrib.Providers.Flipt": "0.0.1" } \ No newline at end of file diff --git a/DotnetSdkContrib.sln b/DotnetSdkContrib.sln index 2c8566d1..57004386 100644 --- a/DotnetSdkContrib.sln +++ b/DotnetSdkContrib.sln @@ -41,6 +41,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Provide EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Statsig.Test", "test\OpenFeature.Contrib.Providers.Statsig.Test\OpenFeature.Contrib.Providers.Statsig.Test.csproj", "{F3080350-B0AB-4D59-B416-50CC38C99087}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Flipt", "src\OpenFeature.Contrib.Providers.Flipt\OpenFeature.Contrib.Providers.Flipt.csproj", "{5ECF7DBF-FE64-40A2-BF39-239DE173DA4B}" +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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,6 +119,14 @@ Global {F3080350-B0AB-4D59-B416-50CC38C99087}.Debug|Any CPU.Build.0 = Debug|Any CPU {F3080350-B0AB-4D59-B416-50CC38C99087}.Release|Any CPU.ActiveCfg = Release|Any CPU {F3080350-B0AB-4D59-B416-50CC38C99087}.Release|Any CPU.Build.0 = Release|Any CPU + {5ECF7DBF-FE64-40A2-BF39-239DE173DA4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5ECF7DBF-FE64-40A2-BF39-239DE173DA4B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5ECF7DBF-FE64-40A2-BF39-239DE173DA4B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5ECF7DBF-FE64-40A2-BF39-239DE173DA4B}.Release|Any CPU.Build.0 = Release|Any CPU + {B446D481-B5A3-4509-8933-C4CF6DA9B147}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -137,5 +149,7 @@ Global {B8C5376B-BAFE-48B8-ABC1-111A93C033F2} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE} {4C7C0E2D-6ECC-4D17-BC5D-18F6BA6F872A} = {0E563821-BD08-4B7F-BF9D-395CAD80F026} {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} EndGlobalSection EndGlobal diff --git a/nuget.config b/nuget.config index 5a0edf43..c5f009d2 100644 --- a/nuget.config +++ b/nuget.config @@ -1,10 +1,10 @@ - + - + diff --git a/release-please-config.json b/release-please-config.json index 08143eca..cabbd73f 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -72,6 +72,16 @@ "extra-files": [ "OpenFeature.Contrib.Providers.Statsig.csproj" ] + }, + "src/OpenFeature.Contrib.Providers.Flipt": { + "package-name": "OpenFeature.Contrib.Providers.Flipt", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "OpenFeature.Contrib.Providers.Flipt.csproj" + ] } }, "changelog-sections": [ diff --git a/src/OpenFeature.Contrib.Providers.Flipt/ClientWrapper/FliptClientWrapper.cs b/src/OpenFeature.Contrib.Providers.Flipt/ClientWrapper/FliptClientWrapper.cs new file mode 100644 index 00000000..ea332706 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/ClientWrapper/FliptClientWrapper.cs @@ -0,0 +1,49 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Flipt.Rest; + +namespace OpenFeature.Contrib.Providers.Flipt.ClientWrapper; + +/// +/// Wrapper for Flipt server sdk client for .net +/// +public class FliptClientWrapper : IFliptClientWrapper +{ + private readonly FliptRestClient _fliptRestClient; + + /// + /// + /// Url of flipt instance + /// Authentication access token + /// Timeout when calling flipt endpoints in seconds + public FliptClientWrapper(string fliptUrl, + string clientToken = "", + int timeoutInSeconds = 30) + { + _fliptRestClient = BuildClient(fliptUrl, clientToken, timeoutInSeconds); + } + + /// + public async Task EvaluateVariantAsync(EvaluationRequest evaluationRequest) + { + return await _fliptRestClient.EvaluateV1VariantAsync(evaluationRequest); + } + + /// + public async Task EvaluateBooleanAsync(EvaluationRequest evaluationRequest) + { + return await _fliptRestClient.EvaluateV1BooleanAsync(evaluationRequest); + } + + private static FliptRestClient BuildClient(string fliptUrl, string clientToken, int timeoutInSeconds = 30) + { + var httpClient = new HttpClient + { + BaseAddress = new Uri(fliptUrl), + Timeout = TimeSpan.FromSeconds(timeoutInSeconds), + DefaultRequestHeaders = { { "Authorization", $"Bearer {clientToken}" } } + }; + return new FliptRestClient(httpClient); + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.Flipt/ClientWrapper/IFliptClientWrapper.cs b/src/OpenFeature.Contrib.Providers.Flipt/ClientWrapper/IFliptClientWrapper.cs new file mode 100644 index 00000000..bd0f0be9 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/ClientWrapper/IFliptClientWrapper.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using Flipt.Rest; + +namespace OpenFeature.Contrib.Providers.Flipt.ClientWrapper; + +/// +/// +public interface IFliptClientWrapper +{ + /// + /// Wrapper to Flipt.io/EvaluateVariantAsync method + /// + /// + /// + Task EvaluateVariantAsync(EvaluationRequest evaluationRequest); + + /// + /// Wrapper to Flipt.io/EvaluateBooleanAsync method + /// + /// + /// + Task EvaluateBooleanAsync(EvaluationRequest evaluationRequest); +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.Flipt/Converters/JsonConverterExtensions.cs b/src/OpenFeature.Contrib.Providers.Flipt/Converters/JsonConverterExtensions.cs new file mode 100644 index 00000000..d1ebbbf4 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/Converters/JsonConverterExtensions.cs @@ -0,0 +1,23 @@ +using System.Text.Json; + +namespace OpenFeature.Contrib.Providers.Flipt.Converters; + +/// +/// Extensions for default JsonConverter behavior +/// +public static class JsonConverterExtensions +{ + /// + /// JsonConverter serializer settings for Flipt to OpenFeature model deserialization + /// + public static readonly JsonSerializerOptions DefaultSerializerSettings = new() + { + WriteIndented = true, + AllowTrailingCommas = true, + Converters = + { + new OpenFeatureStructureConverter(), + new OpenFeatureValueConverter() + } + }; +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.Flipt/Converters/OpenFeatureStructureConverter.cs b/src/OpenFeature.Contrib.Providers.Flipt/Converters/OpenFeatureStructureConverter.cs new file mode 100644 index 00000000..96da85b2 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/Converters/OpenFeatureStructureConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenFeature.Model; + +namespace OpenFeature.Contrib.Providers.Flipt.Converters; + +/// +/// JsonConverter for OpenFeature Structure type +/// +public class OpenFeatureStructureConverter : JsonConverter +{ + /// + public override void Write(Utf8JsonWriter writer, Structure value, JsonSerializerOptions options) + { + var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(value.AsDictionary(), + JsonConverterExtensions.DefaultSerializerSettings)); + jsonDoc.WriteTo(writer); + } + + /// + public override Structure Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var jsonDocument = JsonDocument.ParseValue(ref reader); + var jsonText = jsonDocument.RootElement.GetRawText(); + return new Structure(JsonSerializer.Deserialize>(jsonText, + JsonConverterExtensions.DefaultSerializerSettings)); + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.Flipt/Converters/OpenFeatureValueConverter.cs b/src/OpenFeature.Contrib.Providers.Flipt/Converters/OpenFeatureValueConverter.cs new file mode 100644 index 00000000..6c638dad --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/Converters/OpenFeatureValueConverter.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenFeature.Model; + +namespace OpenFeature.Contrib.Providers.Flipt.Converters; + +/// +/// OpenFeature Value type converter +/// +public class OpenFeatureValueConverter : JsonConverter +{ + /// + public override Value Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = new Value(); + switch (reader.TokenType) + { + case JsonTokenType.String: + return reader.TryGetDateTime(out var dateTimeValue) + ? new Value(dateTimeValue) + : new Value(reader.GetString() ?? string.Empty); + case JsonTokenType.True: + case JsonTokenType.False: + return new Value(reader.GetBoolean()); + case JsonTokenType.Number: + if (reader.TryGetInt32(out var intValue)) return new Value(intValue); + if (reader.TryGetDouble(out var dblValue)) return new Value(dblValue); + break; + case JsonTokenType.StartArray: + return new Value(GenerateValueArray(ref reader, typeToConvert, options)); + case JsonTokenType.StartObject: + return new Value(GetStructure(ref reader, typeToConvert, options)); + } + + return value; + } + + private Structure GetStructure(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var startDepth = reader.CurrentDepth; + var structureDictionary = new Dictionary(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + var key = reader.GetString(); + reader.Read(); + var val = Read(ref reader, typeToConvert, options); + structureDictionary[key ?? string.Empty] = val; + } + + if (reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == startDepth) break; + } + + return new Structure(structureDictionary); + } + + + private IList GenerateValueArray(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) + { + var valuesArray = new List(); + var startDepth = reader.CurrentDepth; + + while (reader.Read()) + switch (reader.TokenType) + { + case JsonTokenType.EndArray when reader.CurrentDepth == startDepth: + return valuesArray; + default: + valuesArray.Add(Read(ref reader, typeToConvert, options)); + break; + } + + return valuesArray; + } + + /// + public override void Write(Utf8JsonWriter writer, Value value, JsonSerializerOptions options) + { + if (value.IsList) + { + writer.WriteStartArray(); + foreach (var val in value.AsList!) + { + var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(val.AsObject, + JsonConverterExtensions.DefaultSerializerSettings)); + jsonDoc.WriteTo(writer); + } + + writer.WriteEndArray(); + } + else + { + var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(value.AsObject, + JsonConverterExtensions.DefaultSerializerSettings)); + jsonDoc.WriteTo(writer); + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.Flipt/FliptExtensions.cs b/src/OpenFeature.Contrib.Providers.Flipt/FliptExtensions.cs new file mode 100644 index 00000000..9f503533 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/FliptExtensions.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using OpenFeature.Contrib.Providers.Flipt.Converters; +using OpenFeature.Model; + +namespace OpenFeature.Contrib.Providers.Flipt; + +/// +/// Extension helper methods +/// +public static class FliptExtensions +{ + /// + /// Transforms openFeature EvaluationContext to a mutable Dictionary that flipt sdk accepts + /// + /// OpenFeature EvaluationContext + /// + public static Dictionary ToStringDictionary(this EvaluationContext evaluationContext) + { + return evaluationContext?.AsDictionary() + .ToDictionary(k => k.Key, + v => JsonSerializer.Serialize(v.Value.AsObject, + JsonConverterExtensions.DefaultSerializerSettings)) ?? + []; + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.Flipt/FliptProvider.cs b/src/OpenFeature.Contrib.Providers.Flipt/FliptProvider.cs new file mode 100644 index 00000000..6d0754ef --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/FliptProvider.cs @@ -0,0 +1,81 @@ +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.Model; + +namespace OpenFeature.Contrib.Providers.Flipt; + +/// +/// FliptProvider is the .NET provider implementation for Flipt.io +/// +/// +/// Accepts an instantiated IFliptClientWrapper instance +/// +public class FliptProvider : FeatureProvider +{ + private static readonly Metadata Metadata = new("Flipt Provider"); + private readonly IFliptToOpenFeatureConverter _fliptToOpenFeatureConverter; + + /// + /// Instantiate a FliptProvider using configuration params + /// + /// Url of flipt instance + /// Namespace used for querying flags + /// Authentication access token + /// Timeout when calling flipt endpoints in seconds + public FliptProvider(string fliptUrl, string namespaceKey = "default", string clientToken = "", + int timeoutInSeconds = 30) : this(new FliptToOpenFeatureConverter(fliptUrl, namespaceKey, clientToken, + timeoutInSeconds)) + { + } + + internal FliptProvider(IFliptToOpenFeatureConverter fliptToOpenFeatureConverter) + { + _fliptToOpenFeatureConverter = fliptToOpenFeatureConverter; + } + + /// + public override Metadata GetMetadata() + { + return Metadata; + } + + /// + public override async Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, + EvaluationContext context = null, + CancellationToken cancellationToken = new()) + { + return await _fliptToOpenFeatureConverter.EvaluateBooleanAsync(flagKey, defaultValue, context); + } + + /// + public override async Task> ResolveStringValueAsync(string flagKey, + string defaultValue, EvaluationContext context = null, + CancellationToken cancellationToken = new()) + { + return await _fliptToOpenFeatureConverter.EvaluateAsync(flagKey, defaultValue, context); + } + + /// + public override async Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, + EvaluationContext context = null, + CancellationToken cancellationToken = new()) + { + return await _fliptToOpenFeatureConverter.EvaluateAsync(flagKey, defaultValue, context); + } + + /// + public override async Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, + EvaluationContext context = null, + CancellationToken cancellationToken = new()) + { + return await _fliptToOpenFeatureConverter.EvaluateAsync(flagKey, defaultValue, context); + } + + /// + public override async Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, + EvaluationContext context = null, + CancellationToken cancellationToken = new()) + { + return await _fliptToOpenFeatureConverter.EvaluateAsync(flagKey, defaultValue, context); + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.Flipt/FliptToOpenFeatureConverter.cs b/src/OpenFeature.Contrib.Providers.Flipt/FliptToOpenFeatureConverter.cs new file mode 100644 index 00000000..cf54c183 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/FliptToOpenFeatureConverter.cs @@ -0,0 +1,150 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Flipt.Rest; +using OpenFeature.Constant; +using OpenFeature.Contrib.Providers.Flipt.ClientWrapper; +using OpenFeature.Contrib.Providers.Flipt.Converters; +using OpenFeature.Error; +using OpenFeature.Model; + +namespace OpenFeature.Contrib.Providers.Flipt; + +/// +/// A wrapper of fliptClient to handle data casting and error mappings to OpenFeature models +/// +public class FliptToOpenFeatureConverter : IFliptToOpenFeatureConverter +{ + private readonly IFliptClientWrapper _fliptClientWrapper; + private readonly string _namespaceKey; + + /// + /// Wrapper that uses Flipt to OpenFeature compliant models + /// + /// Url of flipt instance + /// Namespace used for querying flags + /// Authentication access token + /// Timeout when calling flipt endpoints in seconds + public FliptToOpenFeatureConverter(string fliptUrl, + string namespaceKey = "default", + string clientToken = "", + int timeoutInSeconds = 30) : this(new FliptClientWrapper(fliptUrl, clientToken, timeoutInSeconds), + namespaceKey) + { + } + + internal FliptToOpenFeatureConverter(IFliptClientWrapper fliptClientWrapper, string namespaceKey = "default") + { + _fliptClientWrapper = fliptClientWrapper; + _namespaceKey = namespaceKey; + } + + /// + public async Task> EvaluateAsync(string flagKey, T defaultValue, + EvaluationContext context = null) + { + var evaluationRequest = new EvaluationRequest + { + NamespaceKey = _namespaceKey, + FlagKey = flagKey, + EntityId = context?.TargetingKey ?? "", + Context = context.ToStringDictionary() + }; + + try + { + var evaluationResponse = await _fliptClientWrapper.EvaluateVariantAsync(evaluationRequest); + + if (evaluationResponse.Reason == VariantEvaluationResponseReason.FLAG_DISABLED_EVALUATION_REASON) + return new ResolutionDetails(flagKey, defaultValue, ErrorType.None, + Reason.Disabled); + + if (!evaluationResponse.Match) + return new ResolutionDetails(flagKey, defaultValue, ErrorType.None, + Reason.Default); + try + { + if (string.IsNullOrEmpty(evaluationResponse.VariantAttachment)) + { + var convertedValue = (T)Convert.ChangeType(evaluationResponse.VariantKey, typeof(T)); + return new ResolutionDetails(flagKey, + convertedValue, ErrorType.None, + Reason.TargetingMatch, evaluationResponse.VariantKey); + } + + var deserializedValueObj = JsonSerializer.Deserialize(evaluationResponse.VariantAttachment, + JsonConverterExtensions.DefaultSerializerSettings); + + return new ResolutionDetails(flagKey, + (T)Convert.ChangeType(deserializedValueObj, typeof(T)), + ErrorType.None, Reason.TargetingMatch, evaluationResponse.VariantKey); + } + catch (Exception ex) + { + if (ex is InvalidCastException or FormatException) + throw new TypeMismatchException(ex.Message, ex); + } + } + catch (FliptRestException ex) + { + throw HttpRequestExceptionFromFliptRestException(ex); + } + + return new ResolutionDetails(flagKey, defaultValue, ErrorType.General, Reason.Unknown); + } + + /// + public async Task> EvaluateBooleanAsync(string flagKey, bool defaultValue, + EvaluationContext context = null) + { + try + { + var evaluationRequest = new EvaluationRequest + { + NamespaceKey = _namespaceKey, + FlagKey = flagKey, + EntityId = context?.TargetingKey ?? "", + Context = context.ToStringDictionary() + }; + var boolEvaluationResponse = await _fliptClientWrapper.EvaluateBooleanAsync(evaluationRequest); + return new ResolutionDetails(flagKey, boolEvaluationResponse.Enabled, ErrorType.None, + Reason.TargetingMatch); + } + catch (FliptRestException ex) + { + throw HttpRequestExceptionFromFliptRestException(ex); + } + } + + private static Exception HttpRequestExceptionFromFliptRestException(FliptRestException e) + { + return new HttpRequestException(e.Message, e); + } +} + +/// +/// Contract for fliptClient wrapper +/// +public interface IFliptToOpenFeatureConverter +{ + /// + /// Used for evaluating non-boolean flags. Flipt handles datatypes which is not boolean as variants + /// + /// + /// + /// + /// + /// OpenFeature ResolutionDetails object + Task> EvaluateAsync(string flagKey, T defaultValue, EvaluationContext context = null); + + /// + /// Used for evaluating boolean flags + /// + /// + /// + /// + /// OpenFeature ResolutionDetails object + Task> EvaluateBooleanAsync(string flagKey, bool defaultValue, + EvaluationContext context = null); +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.Flipt/OpenFeature.Contrib.Providers.Flipt.csproj b/src/OpenFeature.Contrib.Providers.Flipt/OpenFeature.Contrib.Providers.Flipt.csproj new file mode 100644 index 00000000..6e3d4e32 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/OpenFeature.Contrib.Providers.Flipt.csproj @@ -0,0 +1,37 @@ + + + + OpenFeature.Contrib.Providers.Flipt + 0.1.0 + $(VersionNumber) + $(VersionNumber) + $(VersionNumber) + Flipt provider for .NET + Jean Andrei de la Cruz Austria + + + + + + <_Parameter1>$(MSBuildProjectName).Test + + + + + + + + + + + + + + + latest + + + + + + diff --git a/src/OpenFeature.Contrib.Providers.Flipt/README.md b/src/OpenFeature.Contrib.Providers.Flipt/README.md new file mode 100644 index 00000000..fdb3d38d --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/README.md @@ -0,0 +1,134 @@ +# Flipt .NET Provider + +The flipt provider allows you to connect to your Flipt 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 **Flipt Feature Flag provider**. + +### .NET Cli + +```shell +dotnet add package OpenFeature.Contrib.Providers.Flipt +``` + +### Package Manager + +```shell +NuGet\Install-Package OpenFeature.Contrib.Providers.Flipt +``` + +### Package Reference + +```xml + +``` + +### Packet cli + +```shell +packet add OpenFeature.Contrib.Providers.Flipt +``` + +### Cake + +```shell +// Install OpenFeature.Contrib.Providers.Flipt as a Cake Addin +#addin nuget:?package=OpenFeature.Contrib.Providers.Flipt + +// Install OpenFeature.Contrib.Providers.Flipt as a Cake Tool +#tool nuget:?package=OpenFeature.Contrib.Providers.Flipt +``` + +## Using the Flipt Provider with the OpenFeature SDK + +To create a Flipt provider you should define provider and pass in the instance `url` (required), `defaultNamespace` and +`token`. + +```csharp +using OpenFeature.Contrib.Providers.Flipt; +using OpenFeature.Model; + +// namespace and clientToken is optional +var featureProvider = new FliptProvider("http://localhost:8080", "default-namespace", "client-token"); + +// Set the featureProvider as the provider for the OpenFeature SDK +await OpenFeature.Api.Instance.SetProviderAsync(featureProvider); + +// Get an OpenFeature client +var client = OpenFeature.Api.Instance.GetClient(); + +// Optional: set EntityId and updated context +var context = EvaluationContext.Builder() + .SetTargetingKey("flipt EntityId") + .Set("extra-data-1", "extra-data-1-value") + .Build(); + +// Evaluate a flag +var val = await client.GetBooleanValueAsync("myBoolFlag", false, context); + +// Print the value of the 'myBoolFlag' feature flag +Console.WriteLine(val); +``` + +# Contribution + +## Code setup + +Since the official [flipt-csharp](https://github.com/flipt-io/flipt-server-sdks/tree/main/flipt-csharp) only supports +dotnet 8.0, it was not utilized in this provider as OpenFeature aims to support a bigger range of dotnet versions. + +### Rest Client using OpenAPI + +To work around this incompatibility, the openapi specification +of [Flipt](https://github.com/flipt-io/flipt/blob/main/openapi.yaml) was +used to generate a REST client using [nswag](https://github.com/RicoSuter/NSwag). + +## Updating the REST Client + +To generate or update the Flipt REST client **manually**, follow these steps: + +_The **Rest client is generated automatically during build time** using the committed `openapi.yaml` file and is saved +in the `/obj/` folder_ + +### 1. Download the OpenAPI Specification + +First, download the latest `openapi.yaml` file from the Flipt GitHub repository. This can be done manually or by using a +command like `curl` in the `/src/OpenFeature.Contrib.Providers.Flipt/`: + +``` +curl https://raw.githubusercontent.com/flipt-io/flipt/refs/heads/main/openapi.yaml -o openapi.yaml +``` + +### 2. Generate the Client Code + +With the `openapi.yml` file in your working directory, run the following `nswag` command to generate the REST client +code. Make sure to correct the command as shown below: + +``` +nswag openapi2csclient /className:FliptRestClient /namespace:Flipt.Rest /input:"openapi.yaml" /output:"./Flipt.Rest.Client.cs" /GenerateExceptionClasses:true /OperationGenerationMode:SingleClientFromPathSegments /JsonLibrary:SystemTextJson /GenerateOptionalParameters:true /GenerateDefaultValues:true /GenerateResponseClasses:true /GenerateClientInterfaces:true /GenerateClientClasses:true /GenerateDtoTypes:true /ExceptionClass:FliptRestException /GenerateNativeRecords:true /UseBaseUrl:false /GenerateBaseUrlProperty:false +``` + +#### Notes + +- Ensure the `nswag` CLI tool is correctly installed and accessible from your terminal or command prompt. +- The command provided generates a C# client for interacting with the Flipt API, leveraging the System.Text.Json library + for JSON serialization/deserialization. +- The generated client will include features such as exception classes, optional parameters, default values, response + classes, client interfaces, DTO types, and native records, according to the specified options. +- This process assumes you're working in a directory that contains the `openapi.yml` file and will generate the + `Flipt.Rest.Client.cs` file in the same directory. + +## Know issues and limitations + +-In `BuildClient()` method +from https://github.com/open-feature/dotnet-sdk-contrib/blob/204144f6df0dacf46e6d52d34dd6b5a223a853f4/src/OpenFeature.Contrib.Providers.Flipt/ClientWrapper/FliptClientWrapper.cs#L41-L47 +a new `HttpClient` is created. In the future it would be better to allow passing of `HttpConnectionFactory` to avoid +problems regarding socket starvation + diff --git a/src/OpenFeature.Contrib.Providers.Flipt/openapi.yaml b/src/OpenFeature.Contrib.Providers.Flipt/openapi.yaml new file mode 100644 index 00000000..8e2c006f --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/openapi.yaml @@ -0,0 +1,2310 @@ +# Generated with protoc-gen-openapi +# https://github.com/google/gnostic/tree/master/cmd/protoc-gen-openapi + +openapi: 3.0.3 +info: + title: api + version: 1.47.0 +servers: + - url: http://localhost:8080 +paths: + /api/v1/namespaces: + get: + tags: + - Flipt + - NamespacesService + operationId: listNamespaces + parameters: + - name: limit + in: query + schema: + type: integer + format: int32 + - name: offset + in: query + schema: + type: integer + format: int32 + - name: pageToken + in: query + schema: + type: string + - name: reference + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/NamespaceList' + post: + tags: + - Flipt + - NamespacesService + operationId: createNamespace + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateNamespaceRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Namespace' + /api/v1/namespaces/{key}: + get: + tags: + - Flipt + - NamespacesService + operationId: getNamespace + parameters: + - name: key + in: path + required: true + schema: + type: string + - name: reference + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Namespace' + put: + tags: + - Flipt + - NamespacesService + operationId: updateNamespace + parameters: + - name: key + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateNamespaceRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Namespace' + delete: + tags: + - Flipt + - NamespacesService + operationId: deleteNamespace + parameters: + - name: key + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: {} + /api/v1/namespaces/{namespaceKey}/flags: + get: + tags: + - Flipt + - FlagsService + operationId: listFlags + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: int32 + - name: offset + in: query + schema: + type: integer + format: int32 + - name: pageToken + in: query + schema: + type: string + - name: reference + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/FlagList' + post: + tags: + - Flipt + - FlagsService + operationId: createFlag + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFlagRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Flag' + /api/v1/namespaces/{namespaceKey}/flags/{flagKey}/rollouts: + get: + tags: + - Flipt + - RolloutsService + operationId: listRollouts + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: int32 + - name: pageToken + in: query + schema: + type: string + - name: reference + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/RolloutList' + post: + tags: + - Flipt + - RolloutsService + operationId: createRollout + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateRolloutRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Rollout' + /api/v1/namespaces/{namespaceKey}/flags/{flagKey}/rollouts/order: + put: + tags: + - Flipt + - RolloutsService + operationId: orderRollouts + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OrderRolloutsRequest' + required: true + responses: + "200": + description: OK + content: {} + /api/v1/namespaces/{namespaceKey}/flags/{flagKey}/rollouts/{id}: + get: + tags: + - Flipt + - RolloutsService + operationId: getRollout + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + - name: reference + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Rollout' + put: + tags: + - Flipt + - RolloutsService + operationId: updateRollout + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateRolloutRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Rollout' + delete: + tags: + - Flipt + - RolloutsService + operationId: deleteRollout + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: {} + /api/v1/namespaces/{namespaceKey}/flags/{flagKey}/rules: + get: + tags: + - Flipt + - RulesService + operationId: listRules + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: int32 + - name: offset + in: query + schema: + type: integer + format: int32 + - name: pageToken + in: query + schema: + type: string + - name: reference + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/RuleList' + post: + tags: + - Flipt + - RulesService + operationId: createRule + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateRuleRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Rule' + /api/v1/namespaces/{namespaceKey}/flags/{flagKey}/rules/order: + put: + tags: + - Flipt + - RulesService + operationId: orderRules + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OrderRulesRequest' + required: true + responses: + "200": + description: OK + content: {} + /api/v1/namespaces/{namespaceKey}/flags/{flagKey}/rules/{id}: + get: + tags: + - Flipt + - RulesService + operationId: getRule + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + - name: reference + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Rule' + put: + tags: + - Flipt + - RulesService + operationId: updateRule + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateRuleRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Rule' + delete: + tags: + - Flipt + - RulesService + operationId: deleteRule + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: {} + /api/v1/namespaces/{namespaceKey}/flags/{flagKey}/rules/{ruleId}/distributions: + post: + tags: + - Flipt + - DistributionsService + operationId: createDistribution + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: ruleId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDistributionRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Distribution' + /api/v1/namespaces/{namespaceKey}/flags/{flagKey}/rules/{ruleId}/distributions/{id}: + put: + tags: + - Flipt + - DistributionsService + operationId: updateDistribution + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: ruleId + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDistributionRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Distribution' + delete: + tags: + - Flipt + - DistributionsService + operationId: deleteDistribution + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: ruleId + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + - name: variantId + in: query + schema: + type: string + responses: + "200": + description: OK + content: {} + /api/v1/namespaces/{namespaceKey}/flags/{flagKey}/variants: + post: + tags: + - Flipt + - VariantsService + operationId: createVariant + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateVariantRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Variant' + /api/v1/namespaces/{namespaceKey}/flags/{flagKey}/variants/{id}: + put: + tags: + - Flipt + - VariantsService + operationId: updateVariant + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateVariantRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Variant' + delete: + tags: + - Flipt + - VariantsService + operationId: deleteVariant + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: flagKey + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: {} + /api/v1/namespaces/{namespaceKey}/flags/{key}: + get: + tags: + - Flipt + - FlagsService + operationId: getFlag + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: key + in: path + required: true + schema: + type: string + - name: reference + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Flag' + put: + tags: + - Flipt + - FlagsService + operationId: updateFlag + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: key + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFlagRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Flag' + delete: + tags: + - Flipt + - FlagsService + operationId: deleteFlag + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: key + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: {} + /api/v1/namespaces/{namespaceKey}/segments: + get: + tags: + - Flipt + - SegmentsService + operationId: listSegments + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: int32 + - name: offset + in: query + schema: + type: integer + format: int32 + - name: pageToken + in: query + schema: + type: string + - name: reference + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SegmentList' + post: + tags: + - Flipt + - SegmentsService + operationId: createSegment + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSegmentRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Segment' + /api/v1/namespaces/{namespaceKey}/segments/{key}: + get: + tags: + - Flipt + - SegmentsService + operationId: getSegment + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: key + in: path + required: true + schema: + type: string + - name: reference + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Segment' + put: + tags: + - Flipt + - SegmentsService + operationId: updateSegment + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: key + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSegmentRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Segment' + delete: + tags: + - Flipt + - SegmentsService + operationId: deleteSegment + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: key + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: {} + /api/v1/namespaces/{namespaceKey}/segments/{segmentKey}/constraints: + post: + tags: + - Flipt + - ConstraintsService + operationId: createConstraint + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: segmentKey + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateConstraintRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Constraint' + /api/v1/namespaces/{namespaceKey}/segments/{segmentKey}/constraints/{id}: + put: + tags: + - Flipt + - ConstraintsService + operationId: updateConstraint + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: segmentKey + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateConstraintRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Constraint' + delete: + tags: + - Flipt + - ConstraintsService + operationId: deleteConstraint + parameters: + - name: namespaceKey + in: path + required: true + schema: + type: string + - name: segmentKey + in: path + required: true + schema: + type: string + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: {} + /auth/v1/method/kubernetes/serviceaccount: + post: + tags: + - AuthenticationMethodKubernetesService + operationId: kubernetesVerifyServiceAccount + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyServiceAccountRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyServiceAccountResponse' + /auth/v1/method/oidc/{provider}/authorize: + get: + tags: + - AuthenticationMethodOIDCService + operationId: oidcAuthorizeURL + parameters: + - name: provider + in: path + required: true + schema: + type: string + - name: state + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AuthorizeURLResponse' + /auth/v1/method/oidc/{provider}/callback: + get: + tags: + - AuthenticationMethodOIDCService + operationId: oidcCallback + parameters: + - name: provider + in: path + required: true + schema: + type: string + - name: code + in: query + schema: + type: string + - name: state + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CallbackResponse' + /auth/v1/method/token: + post: + tags: + - AuthenticationMethodTokenService + operationId: createMethodToken + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTokenRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTokenResponse' + /auth/v1/self: + get: + tags: + - AuthenticationService + operationId: getAuthSelf + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Authentication' + /auth/v1/self/expire: + put: + tags: + - AuthenticationService + operationId: expireAuthSelf + parameters: + - name: expiresAt + in: query + schema: + type: string + format: date-time + responses: + "200": + description: OK + content: {} + /auth/v1/tokens: + get: + tags: + - AuthenticationService + operationId: listAuthTokens + parameters: + - name: method + in: query + schema: + enum: + - METHOD_NONE + - METHOD_TOKEN + - METHOD_OIDC + - METHOD_KUBERNETES + - METHOD_GITHUB + - METHOD_JWT + - METHOD_CLOUD + type: string + format: enum + - name: limit + in: query + schema: + type: integer + format: int32 + - name: pageToken + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ListAuthenticationsResponse' + /auth/v1/tokens/{id}: + get: + tags: + - AuthenticationService + operationId: getAuthToken + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Authentication' + delete: + tags: + - AuthenticationService + operationId: deleteAuthToken + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: {} + /evaluate/v1/batch: + post: + tags: + - EvaluationService + operationId: evaluateBatch + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BatchEvaluationRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/BatchEvaluationResponse' + /evaluate/v1/boolean: + post: + tags: + - EvaluationService + operationId: evaluateBoolean + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluationRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/BooleanEvaluationResponse' + /evaluate/v1/variant: + post: + tags: + - EvaluationService + operationId: evaluateVariant + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluationRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/VariantEvaluationResponse' + /ofrep/v1/configuration: + get: + tags: + - OFREPService + description: OFREP provider configuration + operationId: ofrep.configuration + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/GetProviderConfigurationResponse' + /ofrep/v1/evaluate/flags: + post: + tags: + - OFREPService + description: OFREP bulk flag evaluation + operationId: ofrep.evaluateBulk + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateBulkRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/BulkEvaluationResponse' + /ofrep/v1/evaluate/flags/{key}: + post: + tags: + - OFREPService + description: OFREP single flag evaluation + operationId: ofrep.evaluateFlag + parameters: + - name: key + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluateFlagRequest' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluatedFlag' +components: + schemas: + Authentication: + type: object + properties: + id: + type: string + method: + enum: + - METHOD_NONE + - METHOD_TOKEN + - METHOD_OIDC + - METHOD_KUBERNETES + - METHOD_GITHUB + - METHOD_JWT + - METHOD_CLOUD + type: string + format: enum + expiresAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + metadata: + type: object + additionalProperties: + type: string + AuthorizeURLResponse: + type: object + properties: + authorizeUrl: + type: string + BatchEvaluationRequest: + required: + - requests + type: object + properties: + requestId: + type: string + requests: + type: array + items: + $ref: '#/components/schemas/EvaluationRequest' + reference: + type: string + BatchEvaluationResponse: + type: object + properties: + requestId: + type: string + responses: + type: array + items: + $ref: '#/components/schemas/EvaluationResponse' + requestDurationMillis: + type: number + format: double + BooleanEvaluationResponse: + type: object + properties: + enabled: + type: boolean + reason: + enum: + - UNKNOWN_EVALUATION_REASON + - FLAG_DISABLED_EVALUATION_REASON + - MATCH_EVALUATION_REASON + - DEFAULT_EVALUATION_REASON + type: string + format: enum + requestId: + type: string + requestDurationMillis: + type: number + format: double + timestamp: + type: string + format: date-time + flagKey: + type: string + BulkEvaluationResponse: + required: + - flags + type: object + properties: + flags: + type: array + items: + $ref: '#/components/schemas/EvaluatedFlag' + CacheInvalidation: + type: object + properties: + polling: + $ref: '#/components/schemas/Polling' + CallbackResponse: + type: object + properties: + clientToken: + type: string + authentication: + $ref: '#/components/schemas/Authentication' + Capabilities: + type: object + properties: + cacheInvalidation: + $ref: '#/components/schemas/CacheInvalidation' + flagEvaluation: + $ref: '#/components/schemas/FlagEvaluation' + Constraint: + type: object + properties: + id: + type: string + segmentKey: + type: string + type: + enum: + - UNKNOWN_COMPARISON_TYPE + - STRING_COMPARISON_TYPE + - NUMBER_COMPARISON_TYPE + - BOOLEAN_COMPARISON_TYPE + - DATETIME_COMPARISON_TYPE + - ENTITY_ID_COMPARISON_TYPE + type: string + format: enum + property: + type: string + operator: + type: string + value: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + namespaceKey: + type: string + description: + type: string + CreateConstraintRequest: + required: + - type + - property + - operator + type: object + properties: + segmentKey: + type: string + type: + enum: + - UNKNOWN_COMPARISON_TYPE + - STRING_COMPARISON_TYPE + - NUMBER_COMPARISON_TYPE + - BOOLEAN_COMPARISON_TYPE + - DATETIME_COMPARISON_TYPE + - ENTITY_ID_COMPARISON_TYPE + type: string + format: enum + property: + type: string + operator: + type: string + value: + type: string + namespaceKey: + type: string + description: + type: string + CreateDistributionRequest: + required: + - variantId + - rollout + type: object + properties: + flagKey: + type: string + ruleId: + type: string + variantId: + type: string + rollout: + type: number + format: float + namespaceKey: + type: string + CreateFlagRequest: + required: + - key + - name + - type + type: object + properties: + key: + type: string + name: + type: string + description: + type: string + enabled: + type: boolean + namespaceKey: + type: string + type: + enum: + - VARIANT_FLAG_TYPE + - BOOLEAN_FLAG_TYPE + type: string + format: enum + metadata: + type: object + CreateNamespaceRequest: + required: + - key + - name + type: object + properties: + key: + type: string + name: + type: string + description: + type: string + CreateRolloutRequest: + required: + - rank + type: object + properties: + namespaceKey: + type: string + flagKey: + type: string + rank: + type: integer + format: int32 + description: + type: string + segment: + $ref: '#/components/schemas/RolloutSegment' + threshold: + $ref: '#/components/schemas/RolloutThreshold' + CreateRuleRequest: + required: + - rank + type: object + properties: + flagKey: + type: string + segmentKey: + type: string + rank: + type: integer + format: int32 + namespaceKey: + type: string + segmentKeys: + type: array + items: + type: string + segmentOperator: + enum: + - OR_SEGMENT_OPERATOR + - AND_SEGMENT_OPERATOR + type: string + format: enum + CreateSegmentRequest: + required: + - key + - name + - matchType + type: object + properties: + key: + type: string + name: + type: string + description: + type: string + matchType: + enum: + - ALL_MATCH_TYPE + - ANY_MATCH_TYPE + type: string + format: enum + namespaceKey: + type: string + CreateTokenRequest: + type: object + properties: + name: + type: string + description: + type: string + expiresAt: + type: string + format: date-time + namespaceKey: + type: string + metadata: + type: object + additionalProperties: + type: string + CreateTokenResponse: + type: object + properties: + clientToken: + type: string + authentication: + $ref: '#/components/schemas/Authentication' + CreateVariantRequest: + required: + - key + type: object + properties: + flagKey: + type: string + key: + type: string + name: + type: string + description: + type: string + attachment: + type: string + namespaceKey: + type: string + Distribution: + type: object + properties: + id: + type: string + ruleId: + type: string + variantId: + type: string + rollout: + type: number + format: float + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + ErrorEvaluationResponse: + type: object + properties: + flagKey: + type: string + namespaceKey: + type: string + reason: + enum: + - UNKNOWN_ERROR_EVALUATION_REASON + - NOT_FOUND_ERROR_EVALUATION_REASON + type: string + format: enum + EvaluateBulkRequest: + type: object + properties: + context: + type: object + additionalProperties: + type: string + EvaluateFlagRequest: + type: object + properties: + key: + type: string + context: + type: object + additionalProperties: + type: string + EvaluatedFlag: + type: object + properties: + key: + type: string + reason: + enum: + - UNKNOWN + - DISABLED + - TARGETING_MATCH + - DEFAULT + type: string + format: enum + variant: + type: string + metadata: + type: object + value: + $ref: '#/components/schemas/GoogleProtobufValue' + EvaluationRequest: + required: + - namespaceKey + - flagKey + - entityId + - context + type: object + properties: + requestId: + type: string + namespaceKey: + type: string + flagKey: + type: string + entityId: + type: string + context: + type: object + additionalProperties: + type: string + reference: + type: string + EvaluationResponse: + type: object + properties: + type: + enum: + - VARIANT_EVALUATION_RESPONSE_TYPE + - BOOLEAN_EVALUATION_RESPONSE_TYPE + - ERROR_EVALUATION_RESPONSE_TYPE + type: string + format: enum + booleanResponse: + $ref: '#/components/schemas/BooleanEvaluationResponse' + variantResponse: + $ref: '#/components/schemas/VariantEvaluationResponse' + errorResponse: + $ref: '#/components/schemas/ErrorEvaluationResponse' + Flag: + type: object + properties: + key: + type: string + name: + type: string + description: + type: string + enabled: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + variants: + type: array + items: + $ref: '#/components/schemas/Variant' + namespaceKey: + type: string + type: + enum: + - VARIANT_FLAG_TYPE + - BOOLEAN_FLAG_TYPE + type: string + format: enum + defaultVariant: + $ref: '#/components/schemas/Variant' + metadata: + type: object + FlagEvaluation: + type: object + properties: + supportedTypes: + type: array + items: + type: string + FlagList: + type: object + properties: + flags: + type: array + items: + $ref: '#/components/schemas/Flag' + nextPageToken: + type: string + totalCount: + type: integer + format: int32 + GetProviderConfigurationResponse: + type: object + properties: + name: + type: string + capabilities: + $ref: '#/components/schemas/Capabilities' + GoogleProtobufValue: + description: Represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. + ListAuthenticationsResponse: + type: object + properties: + authentications: + type: array + items: + $ref: '#/components/schemas/Authentication' + nextPageToken: + type: string + Namespace: + type: object + properties: + key: + type: string + name: + type: string + description: + type: string + protected: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + NamespaceList: + type: object + properties: + namespaces: + type: array + items: + $ref: '#/components/schemas/Namespace' + nextPageToken: + type: string + totalCount: + type: integer + format: int32 + OrderRolloutsRequest: + required: + - rolloutIds + type: object + properties: + flagKey: + type: string + namespaceKey: + type: string + rolloutIds: + type: array + items: + type: string + OrderRulesRequest: + required: + - ruleIds + type: object + properties: + flagKey: + type: string + ruleIds: + type: array + items: + type: string + namespaceKey: + type: string + Polling: + type: object + properties: + enabled: + type: boolean + minPollingIntervalMs: + type: integer + format: uint32 + Rollout: + type: object + properties: + id: + type: string + namespaceKey: + type: string + flagKey: + type: string + type: + enum: + - UNKNOWN_ROLLOUT_TYPE + - SEGMENT_ROLLOUT_TYPE + - THRESHOLD_ROLLOUT_TYPE + type: string + format: enum + rank: + type: integer + format: int32 + description: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + segment: + $ref: '#/components/schemas/RolloutSegment' + threshold: + $ref: '#/components/schemas/RolloutThreshold' + RolloutList: + type: object + properties: + rules: + type: array + items: + $ref: '#/components/schemas/Rollout' + nextPageToken: + type: string + totalCount: + type: integer + format: int32 + RolloutSegment: + type: object + properties: + segmentKey: + type: string + value: + type: boolean + segmentKeys: + type: array + items: + type: string + segmentOperator: + enum: + - OR_SEGMENT_OPERATOR + - AND_SEGMENT_OPERATOR + type: string + format: enum + RolloutThreshold: + type: object + properties: + percentage: + type: number + format: float + value: + type: boolean + Rule: + type: object + properties: + id: + type: string + flagKey: + type: string + segmentKey: + type: string + distributions: + type: array + items: + $ref: '#/components/schemas/Distribution' + rank: + type: integer + format: int32 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + namespaceKey: + type: string + segmentKeys: + type: array + items: + type: string + segmentOperator: + enum: + - OR_SEGMENT_OPERATOR + - AND_SEGMENT_OPERATOR + type: string + format: enum + RuleList: + type: object + properties: + rules: + type: array + items: + $ref: '#/components/schemas/Rule' + nextPageToken: + type: string + totalCount: + type: integer + format: int32 + Segment: + type: object + properties: + key: + type: string + name: + type: string + description: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + constraints: + type: array + items: + $ref: '#/components/schemas/Constraint' + matchType: + enum: + - ALL_MATCH_TYPE + - ANY_MATCH_TYPE + type: string + format: enum + namespaceKey: + type: string + SegmentList: + type: object + properties: + segments: + type: array + items: + $ref: '#/components/schemas/Segment' + nextPageToken: + type: string + totalCount: + type: integer + format: int32 + UpdateConstraintRequest: + required: + - type + - property + - operator + type: object + properties: + id: + type: string + segmentKey: + type: string + type: + enum: + - UNKNOWN_COMPARISON_TYPE + - STRING_COMPARISON_TYPE + - NUMBER_COMPARISON_TYPE + - BOOLEAN_COMPARISON_TYPE + - DATETIME_COMPARISON_TYPE + - ENTITY_ID_COMPARISON_TYPE + type: string + format: enum + property: + type: string + operator: + type: string + value: + type: string + namespaceKey: + type: string + description: + type: string + UpdateDistributionRequest: + required: + - variantId + - rollout + type: object + properties: + id: + type: string + flagKey: + type: string + ruleId: + type: string + variantId: + type: string + rollout: + type: number + format: float + namespaceKey: + type: string + UpdateFlagRequest: + required: + - name + type: object + properties: + key: + type: string + name: + type: string + description: + type: string + enabled: + type: boolean + namespaceKey: + type: string + defaultVariantId: + type: string + metadata: + type: object + UpdateNamespaceRequest: + required: + - name + type: object + properties: + key: + type: string + name: + type: string + description: + type: string + UpdateRolloutRequest: + type: object + properties: + id: + type: string + namespaceKey: + type: string + flagKey: + type: string + description: + type: string + segment: + $ref: '#/components/schemas/RolloutSegment' + threshold: + $ref: '#/components/schemas/RolloutThreshold' + UpdateRuleRequest: + type: object + properties: + id: + type: string + flagKey: + type: string + segmentKey: + type: string + namespaceKey: + type: string + segmentKeys: + type: array + items: + type: string + segmentOperator: + enum: + - OR_SEGMENT_OPERATOR + - AND_SEGMENT_OPERATOR + type: string + format: enum + UpdateSegmentRequest: + required: + - name + - matchType + type: object + properties: + key: + type: string + name: + type: string + description: + type: string + matchType: + enum: + - ALL_MATCH_TYPE + - ANY_MATCH_TYPE + type: string + format: enum + namespaceKey: + type: string + UpdateVariantRequest: + required: + - key + type: object + properties: + id: + type: string + flagKey: + type: string + key: + type: string + name: + type: string + description: + type: string + attachment: + type: string + namespaceKey: + type: string + Variant: + type: object + properties: + id: + type: string + flagKey: + type: string + key: + type: string + name: + type: string + description: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + attachment: + type: string + namespaceKey: + type: string + VariantEvaluationResponse: + type: object + properties: + match: + type: boolean + segmentKeys: + type: array + items: + type: string + reason: + enum: + - UNKNOWN_EVALUATION_REASON + - FLAG_DISABLED_EVALUATION_REASON + - MATCH_EVALUATION_REASON + - DEFAULT_EVALUATION_REASON + type: string + format: enum + variantKey: + type: string + variantAttachment: + type: string + requestId: + type: string + requestDurationMillis: + type: number + format: double + timestamp: + type: string + format: date-time + flagKey: + type: string + VerifyServiceAccountRequest: + type: object + properties: + serviceAccountToken: + type: string + VerifyServiceAccountResponse: + type: object + properties: + clientToken: + type: string + authentication: + $ref: '#/components/schemas/Authentication' + securitySchemes: + bearerAuth: + type: http + scheme: bearer + jwtAuth: + type: http + scheme: JWT +security: + - bearerAuth: [] +tags: + - name: AuthenticationMethodKubernetesService + - name: AuthenticationMethodOIDCService + - name: AuthenticationMethodTokenService + - name: AuthenticationService + - name: EvaluationService + - name: Flipt + - name: OFREPService diff --git a/src/OpenFeature.Contrib.Providers.Flipt/version.txt b/src/OpenFeature.Contrib.Providers.Flipt/version.txt new file mode 100644 index 00000000..6c6aa7cb --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/version.txt @@ -0,0 +1 @@ +0.1.0 \ No newline at end of file diff --git a/test/OpenFeature.Contrib.Providers.Flipt.Test/FlipExtensionsTest.cs b/test/OpenFeature.Contrib.Providers.Flipt.Test/FlipExtensionsTest.cs new file mode 100644 index 00000000..21e9ec1d --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.Flipt.Test/FlipExtensionsTest.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using FluentAssertions; +using OpenFeature.Contrib.Providers.Flipt.Converters; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Contrib.Providers.Flipt.Test; + +public class FlipExtensionsTest +{ + [Fact] + public void ToStringDictionary_WithEmptyContext_ShouldReturnEmptyDictionary() + { + var evaluationContext = EvaluationContext.Builder().Build(); + var result = evaluationContext.ToStringDictionary(); + + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + [Fact] + public void ToStringDictionary_WithContext_ShouldReturnADictionaryWithValues() + { + var evaluationContext = EvaluationContext.Builder() + .SetTargetingKey(Guid.NewGuid().ToString()) + .Set("location", "somewhere") + .Build(); + var result = evaluationContext.ToStringDictionary(); + + result.Should().NotBeNull(); + result.Should().NotBeEmpty(); + result.Keys.Should().Contain("location"); + } + + [Fact] + public void ToStringDictionary_WithContextAndIntegerValue_ShouldReturnADictionaryWithStringValues() + { + var evaluationContext = EvaluationContext.Builder() + .SetTargetingKey(Guid.NewGuid().ToString()) + .Set("age", 23) + .Build(); + var result = evaluationContext.ToStringDictionary(); + + result.Should().NotBeNull(); + result.Should().NotBeEmpty(); + result.Keys.Should().Contain("age"); + result["age"].Should().Be("23"); + } + + [Fact] + public void ToStringDictionary_WithContextAndValuesOfStrings_ShouldReturnADictionaryWithSerializedStringValues() + { + var testStructure = new Structure(new Dictionary + { + { "config1", new Value("value1") }, + { "config2", new Value("value2") } + }); + + var evaluationContext = EvaluationContext.Builder() + .SetTargetingKey(Guid.NewGuid().ToString()) + .Set("config", testStructure) + .Build(); + var result = evaluationContext.ToStringDictionary(); + + result.Should().NotBeNull(); + result.Should().NotBeEmpty(); + result.Keys.Should().Contain("config"); + + JsonSerializer + .Deserialize(result["config"], + JsonConverterExtensions.DefaultSerializerSettings).Should() + .BeEquivalentTo(testStructure); + } + + [Fact] + public void ToStringDictionary_WithContextAndMixedValueTypes_ShouldReturnADictionaryWithSerializedValues() + { + var testStructure = new Structure(new Dictionary + { + { "config1", new Value(1) }, + { "config2", new Value("value2") }, + { "config3", new Value(DateTime.Now) } + }); + + var evaluationContext = EvaluationContext.Builder() + .SetTargetingKey(Guid.NewGuid().ToString()) + .Set("config", testStructure) + .Build(); + var result = evaluationContext.ToStringDictionary(); + + result.Should().NotBeNull(); + result.Should().NotBeEmpty(); + result.Keys.Should().Contain("config"); + + var deserialized = JsonSerializer.Deserialize(result["config"], + JsonConverterExtensions.DefaultSerializerSettings); + deserialized.Should().BeEquivalentTo(testStructure); + } + + [Fact] + public void ToStringDictionary_WithContextWithListAndNestedList_ShouldReturnADictionaryWithSerializedValues() + { + var sampleDictionary = new Dictionary(); + sampleDictionary["config2"] = new Value([ + new Value([new Value("element1-1"), new Value("element1-2")]), new Value("element2"), + new Value("element3") + ]); + sampleDictionary["config3"] = new Value(DateTime.Now); + + var testStructure = new Structure(sampleDictionary); + + var evaluationContext = EvaluationContext.Builder() + .SetTargetingKey(Guid.NewGuid().ToString()) + .Set("config", testStructure) + .Build(); + var result = evaluationContext.ToStringDictionary(); + + result.Should().NotBeNull(); + result.Should().NotBeEmpty(); + result.Keys.Should().Contain("config"); + + var deserialized = JsonSerializer.Deserialize(result["config"], + JsonConverterExtensions.DefaultSerializerSettings); + deserialized.Should().BeEquivalentTo(testStructure); + } + + [Fact] + public void ToStringDictionary_WithContextWithNestedStructure_ShouldReturnADictionaryWithSerializedValues() + { + var testStructure = new Structure(new Dictionary + { + { + "config-value-struct", new Value(new Structure(new Dictionary + { + { "nested1", new Value(1) } + })) + }, + { "config-value-value", new Value(new Value(DateTime.Now)) } + }); + + var evaluationContext = EvaluationContext.Builder() + .SetTargetingKey(Guid.NewGuid().ToString()) + .Set("config", testStructure) + .Build(); + var result = evaluationContext.ToStringDictionary(); + + result.Should().NotBeNull(); + result.Should().NotBeEmpty(); + result.Keys.Should().Contain("config"); + + var deserialized = JsonSerializer.Deserialize(result["config"], + JsonConverterExtensions.DefaultSerializerSettings); + deserialized.Should().BeEquivalentTo(testStructure); + } +} \ No newline at end of file diff --git a/test/OpenFeature.Contrib.Providers.Flipt.Test/FliptProviderTest.cs b/test/OpenFeature.Contrib.Providers.Flipt.Test/FliptProviderTest.cs new file mode 100644 index 00000000..07bfd62c --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.Flipt.Test/FliptProviderTest.cs @@ -0,0 +1,138 @@ +using Flipt.Rest; +using FluentAssertions; +using Moq; +using OpenFeature.Contrib.Providers.Flipt.ClientWrapper; +using OpenFeature.Error; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Contrib.Providers.Flipt.Test; + +public class FliptProviderTest +{ + private readonly string _fliptUrl = "http://localhost:8080/"; + + [Fact] + public void CreateFliptProvider_ShouldReturnFliptProvider() + { + // Flipt library always returns a flipt instance + var fliptProvider = new FliptProvider(_fliptUrl); + Assert.NotNull(fliptProvider); + } + + [Fact] + public void CreateFliptProvider_GivenEmptyUrl_ShouldThrowInvalidOperationException() + { + var act = void() => new FliptProvider(""); + act.Should().Throw(); + } + + + [Fact] + public async Task + ResolveNonBooleansAsync_GivenFlagThatHasATypeMismatch_ShouldReturnDefaultValueWithTypeMismatchError() + { + var mockFliptClientWrapper = new Mock(); + const string flagKey = "iamnotadouble"; + mockFliptClientWrapper.Setup(fcw => fcw.EvaluateVariantAsync(It.IsAny())) + .ReturnsAsync(new VariantEvaluationResponse + { + FlagKey = flagKey, + VariantKey = "variant-key", + RequestId = Guid.NewGuid() + .ToString(), + SegmentKeys = ["segment1"], + VariantAttachment = "", + Match = true, + Reason = VariantEvaluationResponseReason.MATCH_EVALUATION_REASON + }); + + var provider = new FliptProvider(new FliptToOpenFeatureConverter(mockFliptClientWrapper.Object)); + + var resolution = async Task>() => + await provider.ResolveDoubleValueAsync(flagKey, 0.0); + await resolution.Should().ThrowAsync(); + } + + [Fact] + public async Task ResolveStringValueAsync_WhenCalled_ShouldCallCorrectMethodFromFliptClientWrapper() + { + const string flagKey = "feature-flag-key"; + var (provider, mockFliptClientWrapper) = GenerateFliptProviderWithMockedDependencies(flagKey); + await provider.ResolveStringValueAsync(flagKey, ""); + mockFliptClientWrapper.Verify( + fcw => fcw.EvaluateVariantAsync(It.Is(er => er.FlagKey == flagKey)), Times.Once); + } + + [Fact] + public async Task ResolveDoubleValueAsync_WhenCalled_ShouldCallCorrectMethodFromFliptClientWrapper() + { + const string flagKey = "feature-flag-key"; + var (provider, mockFliptClientWrapper) = GenerateFliptProviderWithMockedDependencies(flagKey, "0.0"); + await provider.ResolveDoubleValueAsync(flagKey, 0.0); + mockFliptClientWrapper.Verify( + fcw => fcw.EvaluateVariantAsync(It.Is(er => er.FlagKey == flagKey)), Times.Once); + } + + [Fact] + public async Task ResolveIntegerValueAsync_WhenCalled_ShouldCallCorrectMethodFromFliptClientWrapper() + { + const string flagKey = "feature-flag-key"; + var (provider, mockFliptClientWrapper) = GenerateFliptProviderWithMockedDependencies(flagKey, "0"); + await provider.ResolveIntegerValueAsync(flagKey, 0); + mockFliptClientWrapper.Verify( + fcw => fcw.EvaluateVariantAsync(It.Is(er => er.FlagKey == flagKey)), Times.Once); + } + + [Fact] + public async Task ResolveStructureValueAsync_WhenCalled_ShouldCallCorrectMethodFromFliptClientWrapper() + { + const string flagKey = "feature-flag-key"; + var (provider, mockFliptClientWrapper) = + GenerateFliptProviderWithMockedDependencies(flagKey, new Value().AsString!); + await provider.ResolveStructureValueAsync(flagKey, new Value()); + mockFliptClientWrapper.Verify( + fcw => fcw.EvaluateVariantAsync(It.Is(er => er.FlagKey == flagKey)), Times.Once); + } + + [Fact] + public async Task ResolveBooleanValueAsync_WhenCalled_ShouldCallCorrectMethodFromFliptClientWrapper() + { + const string flagKey = "feature-flag-key"; + var (provider, mockFliptClientWrapper) = GenerateFliptProviderWithMockedDependencies(flagKey, "true"); + await provider.ResolveBooleanValueAsync(flagKey, false); + mockFliptClientWrapper.Verify( + fcw => fcw.EvaluateBooleanAsync(It.Is(er => er.FlagKey == flagKey)), Times.Once); + } + + private static (FliptProvider, Mock) GenerateFliptProviderWithMockedDependencies( + string flagKey, string variantKey = "variant-key") + { + var mockFliptClientWrapper = new Mock(); + mockFliptClientWrapper.Setup(fcw => fcw.EvaluateVariantAsync(It.IsAny())) + .ReturnsAsync(new VariantEvaluationResponse + { + FlagKey = flagKey, + VariantKey = variantKey, + RequestId = Guid.NewGuid() + .ToString(), + SegmentKeys = ["segment1"], + VariantAttachment = "", + Match = true, + Reason = VariantEvaluationResponseReason.MATCH_EVALUATION_REASON + }); + + mockFliptClientWrapper.Setup(fcw => fcw.EvaluateBooleanAsync(It.IsAny())) + .ReturnsAsync(new BooleanEvaluationResponse + { + FlagKey = flagKey, + RequestId = Guid.NewGuid() + .ToString(), + Enabled = true, + Reason = BooleanEvaluationResponseReason.MATCH_EVALUATION_REASON + }); + + return (new FliptProvider(new FliptToOpenFeatureConverter(mockFliptClientWrapper.Object)), + mockFliptClientWrapper); + } +} \ No newline at end of file diff --git a/test/OpenFeature.Contrib.Providers.Flipt.Test/FliptToOpenFeatureConverterTest.cs b/test/OpenFeature.Contrib.Providers.Flipt.Test/FliptToOpenFeatureConverterTest.cs new file mode 100644 index 00000000..2e41215b --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.Flipt.Test/FliptToOpenFeatureConverterTest.cs @@ -0,0 +1,201 @@ +using System.Net; +using Flipt.Rest; +using FluentAssertions; +using Moq; +using OpenFeature.Constant; +using OpenFeature.Contrib.Providers.Flipt.ClientWrapper; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Contrib.Providers.Flipt.Test; + +public class FliptToOpenFeatureConverterTest +{ + // EvaluateBooleanAsync Tests + [Theory] + [InlineData(HttpStatusCode.NotFound, ErrorType.FlagNotFound, false)] + [InlineData(HttpStatusCode.BadRequest, ErrorType.TypeMismatch, false)] + [InlineData(HttpStatusCode.InternalServerError, ErrorType.ProviderNotReady, false)] + [InlineData(HttpStatusCode.Forbidden, ErrorType.ProviderNotReady, false)] + [InlineData(HttpStatusCode.Ambiguous, ErrorType.General, false)] + public async Task EvaluateBooleanAsync_GivenHttpRequestException_ShouldHandleHttpRequestException( + HttpStatusCode thrownStatusCode, ErrorType expectedOpenFeatureErrorType, bool fallbackValue) + { + var mockFliptClientWrapper = new Mock(); + mockFliptClientWrapper.Setup(fcw => + fcw.EvaluateBooleanAsync(It.IsAny())) + .ThrowsAsync(new FliptRestException("", (int)thrownStatusCode, "", null, null)); + + var fliptToOpenFeature = new FliptToOpenFeatureConverter(mockFliptClientWrapper.Object); + var resolution = async Task>() => + await fliptToOpenFeature.EvaluateBooleanAsync("flagKey", fallbackValue); + + await resolution.Should().ThrowAsync(); + } + + [Theory] + [InlineData("show-feature", true)] + [InlineData("show-feature", false)] + public async Task EvaluateBooleanAsync_GivenExistingFlag_ShouldReturnFlagValue(string flagKey, + bool valueFromSrc) + { + var mockFliptClientWrapper = new Mock(); + mockFliptClientWrapper.Setup(fcw => fcw.EvaluateBooleanAsync(It.IsAny())) + .ReturnsAsync(new BooleanEvaluationResponse + { + Enabled = valueFromSrc, + FlagKey = flagKey, + RequestId = Guid.NewGuid().ToString() + }); + + var fliptToOpenFeature = new FliptToOpenFeatureConverter(mockFliptClientWrapper.Object); + var resolution = await fliptToOpenFeature.EvaluateBooleanAsync("show-feature", false); + + resolution.FlagKey.Should().Be(flagKey); + resolution.Value.Should().Be(valueFromSrc); + resolution.Reason.Should().Be(Reason.TargetingMatch); + } + + [Theory] + [InlineData("show-feature", false)] + [InlineData("show-feature", true)] + public async Task EvaluateBooleanAsync_GivenNonExistentFlag_ShouldReturnDefaultValueWithFlagNotFoundError( + string flagKey, bool fallBackValue) + { + var mockFliptClientWrapper = new Mock(); + mockFliptClientWrapper.Setup(fcw => fcw.EvaluateBooleanAsync(It.IsAny())) + .ThrowsAsync(new FliptRestException("", (int)HttpStatusCode.NotFound, "", null, null)); + + var fliptToOpenFeature = new FliptToOpenFeatureConverter(mockFliptClientWrapper.Object); + var resolution = async Task>() => + await fliptToOpenFeature.EvaluateBooleanAsync("flagKey", fallBackValue); + + await resolution.Should().ThrowAsync(); + } + + // EvaluateAsync Tests + + [Theory] + [InlineData(HttpStatusCode.NotFound, ErrorType.FlagNotFound, 0.0)] + [InlineData(HttpStatusCode.BadRequest, ErrorType.TypeMismatch, 0.0)] + [InlineData(HttpStatusCode.InternalServerError, ErrorType.ProviderNotReady, 0.0)] + [InlineData(HttpStatusCode.Forbidden, ErrorType.ProviderNotReady, 0.0)] + [InlineData(HttpStatusCode.Ambiguous, ErrorType.General, 0.0)] + public async Task EvaluateAsync_GivenHttpRequestException_ShouldHandleHttpRequestException( + HttpStatusCode thrownStatusCode, ErrorType expectedOpenFeatureErrorType, double fallbackValue) + { + var mockFliptClientWrapper = new Mock(); + mockFliptClientWrapper.Setup(fcw => + fcw.EvaluateVariantAsync(It.IsAny())) + .ThrowsAsync(new FliptRestException("", (int)thrownStatusCode, "", null, null)); + + var fliptToOpenFeature = new FliptToOpenFeatureConverter(mockFliptClientWrapper.Object); + var resolution = async Task>() => + await fliptToOpenFeature.EvaluateAsync("flagKey", fallbackValue); + + await resolution.Should().ThrowAsync(); + } + + [Theory] + [InlineData("variant-flag", 1.0, 1.0)] + [InlineData("variant-flag", "thisisastring", "thisisastring")] + [InlineData("variant-flag", 1, 1)] + public async Task EvaluateAsync_GivenExistingVariantFlagWhichIsNotAnObject_ShouldReturnFlagValue(string flagKey, + object valueFromSrc, object? expectedValue = null, string variantAttachment = "") + { + var mockFliptClientWrapper = new Mock(); + mockFliptClientWrapper.Setup(fcw => fcw.EvaluateVariantAsync(It.IsAny())) + .ReturnsAsync(new VariantEvaluationResponse + { + FlagKey = flagKey, + VariantKey = valueFromSrc.ToString() ?? string.Empty, + RequestId = Guid.NewGuid().ToString(), + SegmentKeys = ["segment1"], + VariantAttachment = variantAttachment, + Match = true, + Reason = VariantEvaluationResponseReason.MATCH_EVALUATION_REASON + }); + + var fliptToOpenFeature = new FliptToOpenFeatureConverter(mockFliptClientWrapper.Object); + var resolution = await fliptToOpenFeature.EvaluateAsync(flagKey, valueFromSrc); + + resolution.FlagKey.Should().Be(flagKey); + resolution.Variant.Should().Be(valueFromSrc.ToString() ?? string.Empty); + resolution.Value.Should().BeEquivalentTo(expectedValue?.ToString()); + resolution.Reason.Should().Be(Reason.TargetingMatch); + } + + [Fact] + public async Task EvaluateAsync_GivenExistingVariantFlagAndWithAnObject_ShouldReturnFlagValue() + { + const string flagKey = "variant-flag"; + const string variantKey = "variant-A"; + const string valueFromSrc = """ + { + "name": "Mr. Robinson", + "age": 12, + } + """; + var expectedValue = new Value(new Structure(new Dictionary + { + { "name", new Value("Mr. Robinson") }, { "age", new Value(12) } + })); + + var mockFliptClientWrapper = new Mock(); + mockFliptClientWrapper.Setup(fcw => fcw.EvaluateVariantAsync(It.IsAny())) + .ReturnsAsync(new VariantEvaluationResponse + { + FlagKey = flagKey, + VariantKey = variantKey, + RequestId = Guid.NewGuid().ToString(), + SegmentKeys = ["segment1"], + VariantAttachment = valueFromSrc, + Match = true, + Reason = VariantEvaluationResponseReason.MATCH_EVALUATION_REASON + }); + + var fliptToOpenFeature = new FliptToOpenFeatureConverter(mockFliptClientWrapper.Object); + var resolution = await fliptToOpenFeature.EvaluateAsync(flagKey, new Value()); + + resolution.FlagKey.Should().Be(flagKey); + resolution.Variant.Should().Be(variantKey); + resolution.Value.Should().BeEquivalentTo(expectedValue); + } + + + [Fact] + public async Task + EvaluateVariantAsync_GivenNonExistentFlagWithNonNestedFallback_ShouldReturnDefaultValueWithFlagNotFoundError() + { + var fallbackValue = new Value(new Structure(new Dictionary + { + { "name", new Value("Mr. Robinson") }, { "age", new Value(12) } + })); + var mockFliptClientWrapper = new Mock(); + mockFliptClientWrapper.Setup(fcw => fcw.EvaluateVariantAsync(It.IsAny())) + .ThrowsAsync(new FliptRestException("", (int)HttpStatusCode.NotFound, "", null, null)); + + var fliptToOpenFeature = new FliptToOpenFeatureConverter(mockFliptClientWrapper.Object); + var resolution = async Task>() => + await fliptToOpenFeature.EvaluateAsync("non-existent-flag", fallbackValue); + + await resolution.Should().ThrowAsync(); + } + + + [Fact] + public async Task + EvaluateVariantAsync_GivenNonExistentFlagWithNestedFallback_ShouldReturnDefaultValueWithFlagNotFoundError() + { + var fallbackValue = new Value(""); + var mockFliptClientWrapper = new Mock(); + mockFliptClientWrapper.Setup(fcw => fcw.EvaluateVariantAsync(It.IsAny())) + .ThrowsAsync(new FliptRestException("", (int)HttpStatusCode.NotFound, "", null, null)); + + var fliptToOpenFeature = new FliptToOpenFeatureConverter(mockFliptClientWrapper.Object); + var resolution = async Task>() => + await fliptToOpenFeature.EvaluateAsync("non-existent-flag", fallbackValue); + + await resolution.Should().ThrowAsync(); + } +} \ No newline at end of file diff --git a/test/OpenFeature.Contrib.Providers.Flipt.Test/OpenFeature.Contrib.Providers.Flipt.Test.csproj b/test/OpenFeature.Contrib.Providers.Flipt.Test/OpenFeature.Contrib.Providers.Flipt.Test.csproj new file mode 100644 index 00000000..8e41387a --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.Flipt.Test/OpenFeature.Contrib.Providers.Flipt.Test.csproj @@ -0,0 +1,18 @@ + + + + enable + enable + + false + true + latest + + + + + + + + +