diff --git a/.github/component_owners.yml b/.github/component_owners.yml index eaed7942..935499fe 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -20,6 +20,8 @@ components: - toddbaert src/OpenFeature.Contrib.Providers.Statsig: - jenshenneberg + src/OpenFeature.Contrib.Providers.Flipt: + - dmitryrogov # test/ test/OpenFeature.Contrib.Hooks.Otel.Test: @@ -41,6 +43,8 @@ components: - toddbaert test/src/OpenFeature.Contrib.Providers.Statsig.Test: - jenshenneberg + test/src/OpenFeature.Contrib.Providers.Flipt.Test: + - dmitryrogov ignored-authors: - renovate-bot diff --git a/DotnetSdkContrib.sln b/DotnetSdkContrib.sln index 2c8566d1..d244f940 100644 --- a/DotnetSdkContrib.sln +++ b/DotnetSdkContrib.sln @@ -37,6 +37,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Provide EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Flagd.E2e.ProcessTest", "test\OpenFeature.Contrib.Providers.Flagd.E2e.ProcessTest\OpenFeature.Contrib.Providers.Flagd.E2e.ProcessTest.csproj", "{B8C5376B-BAFE-48B8-ABC1-111A93C033F2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Flipt", "src\OpenFeature.Contrib.Providers.Flipt\OpenFeature.Contrib.Providers.Flipt.csproj", "{9649C012-F66B-46BB-A2C6-A3E814F4484E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Flipt.Test", "test\OpenFeature.Contrib.Providers.Flipt.Test\OpenFeature.Contrib.Providers.Flipt.Test.csproj", "{D8CE4357-82B4-4176-9273-B1B76EF1D3C9}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Statsig", "src\OpenFeature.Contrib.Providers.Statsig\OpenFeature.Contrib.Providers.Statsig.csproj", "{4C7C0E2D-6ECC-4D17-BC5D-18F6BA6F872A}" 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}" @@ -107,6 +111,14 @@ Global {B8C5376B-BAFE-48B8-ABC1-111A93C033F2}.Debug|Any CPU.Build.0 = Debug|Any CPU {B8C5376B-BAFE-48B8-ABC1-111A93C033F2}.Release|Any CPU.ActiveCfg = Release|Any CPU {B8C5376B-BAFE-48B8-ABC1-111A93C033F2}.Release|Any CPU.Build.0 = Release|Any CPU + {9649C012-F66B-46BB-A2C6-A3E814F4484E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9649C012-F66B-46BB-A2C6-A3E814F4484E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9649C012-F66B-46BB-A2C6-A3E814F4484E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9649C012-F66B-46BB-A2C6-A3E814F4484E}.Release|Any CPU.Build.0 = Release|Any CPU + {D8CE4357-82B4-4176-9273-B1B76EF1D3C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8CE4357-82B4-4176-9273-B1B76EF1D3C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8CE4357-82B4-4176-9273-B1B76EF1D3C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8CE4357-82B4-4176-9273-B1B76EF1D3C9}.Release|Any CPU.Build.0 = Release|Any CPU {4C7C0E2D-6ECC-4D17-BC5D-18F6BA6F872A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4C7C0E2D-6ECC-4D17-BC5D-18F6BA6F872A}.Debug|Any CPU.Build.0 = Debug|Any CPU {4C7C0E2D-6ECC-4D17-BC5D-18F6BA6F872A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -135,6 +147,8 @@ Global {4A2C6E0F-8A23-454F-8019-AE3DD91AA193} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE} {2ACD9150-A8F4-450E-B49A-C628895992BF} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE} {B8C5376B-BAFE-48B8-ABC1-111A93C033F2} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE} + {9649C012-F66B-46BB-A2C6-A3E814F4484E} = {0E563821-BD08-4B7F-BF9D-395CAD80F026} + {D8CE4357-82B4-4176-9273-B1B76EF1D3C9} = {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} EndGlobalSection diff --git a/release-please-config.json b/release-please-config.json index e13f0342..4c338592 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -71,6 +71,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/AttachmentParser.cs b/src/OpenFeature.Contrib.Providers.Flipt/AttachmentParser.cs new file mode 100644 index 00000000..0554de65 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/AttachmentParser.cs @@ -0,0 +1,141 @@ +using OpenFeature.Error; +using OpenFeature.Model; +using System.Buffers.Text; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; + +namespace OpenFeature.Contrib.Providers.Flipt +{ + internal static class AttachmentParser + { + /// + /// Converts the JSON string representation of a number to its double-precision + /// floating-point number equivalent. + /// + /// Attachment. + /// Double-precision floating-point number result. + /// true if attachment was converted successfully; otherwise false. + public static bool TryParseDouble(string attachment, out double value) + { + if (string.IsNullOrEmpty(attachment)) + { + value = default; + return false; + } + + return Utf8Parser.TryParse(Encoding.UTF8.GetBytes(attachment), out value, out int _); ; + } + + /// + /// Converts the JSON string representation of a number to its 32-bit signed integer equivalent. + /// + /// Attachment. + /// 32-bit signed integer result. + /// true if attachment was converted successfully; otherwise false. + public static bool TryParseInteger(string attachment, out int value) + { + if (string.IsNullOrEmpty(attachment)) + { + value = default; + return false; + } + + return Utf8Parser.TryParse(Encoding.UTF8.GetBytes(attachment), out value, out int _); + } + + /// + /// Converts the JSON string. + /// + /// Attachment. + /// String result. + /// true if attachment was converted successfully; otherwise false. + public static bool TryParseString(string attachment, out string value) + { + if (string.IsNullOrEmpty(attachment)) + { + value = default; + return false; + } + + value = attachment.Trim('"'); + return true; + } + + /// + /// Attempts to parse a JSON attachment into a Value object. + /// It checks if the attachment is null or empty and tries to parse it using STJ. + /// If successful, it converts the parsed JSON element into a Value object. + /// + /// JSON string attachment. + /// Value result. + /// true if attachment was converted successfully; otherwise false. + public static bool TryParseJsonValue(string attachment, out Value value) + { + value = null; + + if (string.IsNullOrEmpty(attachment)) + { + return false; + } + + try + { + value = ConvertJsonElementToValue(JsonDocument.Parse(attachment).RootElement); + return true; + } + catch + { + return false; + } + } + + private static Value ConvertJsonElementToValue(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + { + if (element.TryGetDateTime(out var dateTimeValue)) + { + return new Value(dateTimeValue); + } + + return new Value(element.GetString()); + } + case JsonValueKind.Number: + { + return new Value(element.GetDouble()); + } + case JsonValueKind.True: + case JsonValueKind.False: + return new Value(element.GetBoolean()); + case JsonValueKind.Object: + { + var structureValues = new Dictionary(); + foreach (JsonProperty property in element.EnumerateObject()) + { + structureValues.Add(property.Name, ConvertJsonElementToValue(property.Value)); + } + + return new Value(new Structure(structureValues)); + } + case JsonValueKind.Array: + { + var arrayValues = new List(); + foreach (JsonElement arrayElement in element.EnumerateArray()) + { + arrayValues.Add(ConvertJsonElementToValue(arrayElement)); + } + + return new Value(arrayValues); + } + case JsonValueKind.Null: + case JsonValueKind.Undefined: + return new Value(); + default: + throw new ParseErrorException($"Invalid variant value: {element.GetRawText()}"); + } + } + } +} diff --git a/src/OpenFeature.Contrib.Providers.Flipt/FliptConverter.cs b/src/OpenFeature.Contrib.Providers.Flipt/FliptConverter.cs new file mode 100644 index 00000000..ee1ebed1 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/FliptConverter.cs @@ -0,0 +1,109 @@ +using Flipt.Evaluation; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Model; +using System.Diagnostics; + +namespace OpenFeature.Contrib.Providers.Flipt +{ + internal static class FliptConverter + { + /// + /// Converts an EvaluationReason value into a corresponding string representation based on predefined reasons. + /// + /// Predefined OpenFeature reason. + /// + public static string ConvertReason(EvaluationReason reason) + { + switch (reason) + { + case EvaluationReason.UnknownEvaluationReason: + return Reason.Unknown; + case EvaluationReason.FlagDisabledEvaluationReason: + return Reason.Disabled; + case EvaluationReason.MatchEvaluationReason: + return Reason.TargetingMatch; + case EvaluationReason.DefaultEvaluationReason: + return Reason.Default; + default: + return Reason.Default; + } + } + + /// + /// Creates Flipt evaluation request. + /// + /// Flag key. + /// Evaluation context. + /// Provider configuration. + /// Flipt evaluation request. + /// Unable to convert context value. + public static EvaluationRequest CreateRequest(string flagKey, EvaluationContext context, FliptProviderConfiguration config) + { + var request = new EvaluationRequest + { + NamespaceKey = config.Namespace, + FlagKey = flagKey + }; + + if (Activity.Current != null) + { + request.RequestId = Activity.Current.Id; + } + + if (context == null || context.Count == 0) + { + return request; + } + + foreach (var item in context) + { + var key = item.Key; + var value = item.Value; + + if (value.IsNull || value.IsList || value.IsStructure) + { + // Skip null, lists and complex objects + continue; + } + + if (key == config.TargetingKey && value.IsString) + { + // Skip targeting key and add its value as EntityId to request + request.EntityId = value.AsString; + continue; + } + + if (key == config.RequestIdKey && value.IsString) + { + // Skip request id key and add its value as RequestId to request + request.RequestId = value.AsString; + continue; + } + + if (value.IsString) + { + request.Context.Add(key, value.AsString); + } + else if (value.IsBoolean) + { + request.Context.Add(key, value.AsBoolean.ToString()); + } + else if (value.IsNumber) + { + request.Context.Add(key, value.AsDouble.ToString()); + } + else if (value.IsDateTime) + { + request.Context.Add(key, $"{value.AsDateTime.Value:o}"); + } + else + { + throw new InvalidContextException($"Unable to convert context value with key: {key}."); + } + } + + return request; + } + } +} diff --git a/src/OpenFeature.Contrib.Providers.Flipt/FliptProvider.cs b/src/OpenFeature.Contrib.Providers.Flipt/FliptProvider.cs new file mode 100644 index 00000000..43c6d652 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/FliptProvider.cs @@ -0,0 +1,155 @@ +using Grpc.Core; +using Grpc.Net.Client; +using OpenFeature.Error; +using OpenFeature.Model; +using System; +using System.Threading; +using System.Threading.Tasks; +using static Flipt.Evaluation.EvaluationService; +using EvaluationRequest = Flipt.Evaluation.EvaluationRequest; +using Metadata = OpenFeature.Model.Metadata; + +namespace OpenFeature.Contrib.Providers.Flipt +{ + /// + /// Flipt feature provider. + /// + public sealed class FliptProvider : FeatureProvider + { + private static readonly Metadata Metadata = new Metadata("Flipt"); + private readonly EvaluationServiceClient _client; + private readonly FliptProviderConfiguration _configuration; + + /// + /// Creates new instance of + /// + /// Flipt provider options. + public FliptProvider(FliptProviderConfiguration configuration) + { + _configuration = configuration; + var channel = GrpcChannel.ForAddress(configuration.ServiceUri); + _client = new EvaluationServiceClient(channel); + } + + /// + /// Creates new instance of + /// + /// + /// Flipt provider options. + internal FliptProvider(EvaluationServiceClient evaluationServiceClient, FliptProviderConfiguration configuration) + { + _configuration = configuration; + _client = evaluationServiceClient; + } + + /// + public override Metadata GetMetadata() + { + return Metadata; + } + + /// + public override Task> ResolveBooleanValue(string flagKey, bool defaultValue, + EvaluationContext context = null) + { + return _configuration.UseBooleanEvaluation + ? ResolveBooleanAsync(flagKey, context) + : ResolveVariantAsync(flagKey, defaultValue, context, bool.TryParse); + } + + /// + public override Task> ResolveDoubleValue(string flagKey, double defaultValue, + EvaluationContext context = null) + { + return ResolveVariantAsync(flagKey, defaultValue, context, AttachmentParser.TryParseDouble); + } + + /// + public override Task> ResolveIntegerValue(string flagKey, int defaultValue, + EvaluationContext context = null) + { + return ResolveVariantAsync(flagKey, defaultValue, context, AttachmentParser.TryParseInteger); + } + + /// + public override Task> ResolveStringValue(string flagKey, string defaultValue, + EvaluationContext context = null) + { + return ResolveVariantAsync(flagKey, defaultValue, context, AttachmentParser.TryParseString); + } + + /// + public override Task> ResolveStructureValue(string flagKey, Value defaultValue, + EvaluationContext context = null) + { + return ResolveVariantAsync(flagKey, defaultValue, context, AttachmentParser.TryParseJsonValue); + } + + private async Task> ResolveBooleanAsync(string flagKey, EvaluationContext context) + { + var request = FliptConverter.CreateRequest(flagKey, context, _configuration); + var response = await SendRequestAsync(_client.BooleanAsync, request); + return new ResolutionDetails( + response.FlagKey, + response.Enabled, + reason: FliptConverter.ConvertReason(response.Reason)); + } + + internal async Task> ResolveVariantAsync(string flagKey, T defaultValue, + EvaluationContext context, TryParseDelegate tryParse) + { + var request = FliptConverter.CreateRequest(flagKey, context, _configuration); + var response = await SendRequestAsync(_client.VariantAsync, request); + + if (!response.Match) + return new ResolutionDetails( + response.FlagKey, + defaultValue, + reason: FliptConverter.ConvertReason(response.Reason)); + + if (tryParse(response.VariantAttachment, out var value)) + return new ResolutionDetails( + response.FlagKey, + value, + variant: response.VariantKey, + reason: FliptConverter.ConvertReason(response.Reason)); + throw new ParseErrorException( + $"Can't convert value \"{response.VariantAttachment}\" to \"{typeof(T).Name}\" type"); + } + + internal static async Task SendRequestAsync(SendRequestDelegate sendRequest, + EvaluationRequest request) + { + try + { + return await sendRequest(request); + } + catch (RpcException ex) + { + if (ex.StatusCode == StatusCode.NotFound) + { + throw new FlagNotFoundException(ex.Status.Detail); + } + + if (ex.StatusCode == StatusCode.InvalidArgument) + { + throw new InvalidContextException(ex.Status.Detail); + } + + throw new GeneralException(ex.Message, ex); + } + catch (Exception ex) + { + throw new GeneralException(ex.Message, ex); + } + } + + internal delegate bool TryParseDelegate(string value, out T result); + + internal delegate AsyncUnaryCall SendRequestDelegate( + EvaluationRequest request, + Grpc.Core.Metadata headers = null, + DateTime? deadline = null, + CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.Flipt/FliptProviderConfiguration.cs b/src/OpenFeature.Contrib.Providers.Flipt/FliptProviderConfiguration.cs new file mode 100644 index 00000000..021d908c --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/FliptProviderConfiguration.cs @@ -0,0 +1,38 @@ +using System; + +namespace OpenFeature.Contrib.Providers.Flipt +{ + /// + /// Flipt provider configuration + /// + public class FliptProviderConfiguration + { + /// + /// Flipt service address + /// + public Uri ServiceUri { get; set; } + + /// + /// Namespace + /// + /// + public string Namespace { get; set; } + + /// + /// Context key whose value will be used as EntityId + /// + /// + public string TargetingKey { get; set; } + + /// + /// Context key whose value will be used as RequestId + /// + public string RequestIdKey { get; set; } + + /// + /// Determines whether to use Boolean Evaluation or Variant Evaluation API + /// + /// + public bool UseBooleanEvaluation { get; set; } + } +} 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..0429805b --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/OpenFeature.Contrib.Providers.Flipt.csproj @@ -0,0 +1,28 @@ + + + + OpenFeature.Contrib.Providers.Flipt + 0.0.1 + $(VersionNumber) + $(VersionNumber) + $(VersionNumber) + Flipt provider for .NET + README.md + Rogov Dmitry + + + + + + + + + + <_Parameter1>$(MSBuildProjectName).Test + + + + + + + diff --git a/src/OpenFeature.Contrib.Providers.Flipt/README.md b/src/OpenFeature.Contrib.Providers.Flipt/README.md new file mode 100644 index 00000000..0dec45af --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/README.md @@ -0,0 +1,195 @@ +# Flipt .NET Provider + +An OpenFeature Provider which enables the use of the Flipt Server-Side SDK. + +# .NET SDK usage + +## Install dependencies + +We will first install the **flipt provider**. + +#### .NET CLI +```shell +dotnet add package OpenFeature.Contrib.Providers.Flipt +``` +#### Package Manager + +```shell +NuGet\Install-Package OpenFeature.Contrib.Providers.Flipt +``` +#### Package Reference + +```xml + +``` +#### Paket CLI + +```shell +paket add OpenFeature.Contrib.Providers.Flagd +``` + + +## Using the Flipt Provider with the OpenFeature SDK + +Using the Flip provider assumes that you have installed and running Flipt service. Instructions on how to do this can be found in the [official documentation](https://docs.flipt.io/self-hosted/overview) + +The following example shows how to use the Flipt provider with the OpenFeature SDK. + +*appsettings.json* +```json +{ + "OpenFeature": { + "Provider": { + "Flipt": { + "ServiceUri": "http://localhost:9000", + "Namespace": "default", + "TargetingKey": "UserId", + "RequestIdKey": "RequestId", + "UseBooleanEvaluation": false + } + } + } +} +``` + +*Program.cs* + +```csharp +using OpenFeature.Contrib.Providers.Flipt; + +var builder = WebApplication.CreateBuilder(args); + +// Get provider config from configuration +var config = builder.Configuration.GetSection("OpenFeature:Provider:Flipt").Get(); + +/* Or create config directly: +var config = new FliptProviderConfiguration +{ + ServiceUri = new Uri("http://localhost:9000"), + Namespace = "default", + TargetingKey = "UserId", + RequestIdKey = "RequestId", + UseBooleanEvaluation = false +};*/ + +// Create an instance of Flipt provider +var fliptProvider = new FliptProvider(config); + +// Set the Flipt Provider as the provider for the OpenFeature SDK +await OpenFeature.Api.Instance.SetProviderAsync(fliptProvider); + +// Create OpenFeature client with current provider +var client = OpenFeature.Api.Instance.GetClient("my-app"); + +// Resolve boolean flag value +var isTestFlagEnabled = await client.GetBooleanValue("test-flag", false, null); + +System.Console.WriteLine($"Test flag enabled: {isTestFlagEnabled}"); +``` + +## Boolean flag evaluation +To perform Boolean flag evaluation, such as `GetBooleanValue` or `GetBooleanDetail` you can utilize [Boolean Evaluation](https://docs.flipt.io/reference/evaluation/boolean-evaluation) or [Variant Evaluation](https://docs.flipt.io/reference/evaluation/variant-evaluation) APIs. + +The preferred method can be selected by configuring the `FliptProviderConfiguration.UseBooleanEvaluation` parameter. + +Note that when using the Variant Evaluation API, the value of the variant **must** be a boolean, represented as either `"true"` or `"false"`. + +## Using EvaluationContext + +Flipt has the concept of [Context](https://docs.flipt.io/concepts#context) where you can evaluate a flag based on properties. The OpenFeature SDK has the concept of an [EvaluationContext](https://openfeature.dev/specification/sections/evaluation-context) which is a dictionary of string keys and values. The Flipt provider will map the EvaluationContext to a Flipt Context. + +Since Flipt Context is a simple array of objects like `[{"key": "value"}]`, the provider only supports primitive types such as `number`, `string`, `date-time` and `boolean`. Complex types like `list` or `structure` will be ingnored. + +For example context: +```csharp +var context = EvaluationContext.Builder() + .Set("role", "user") + .Set("age", 22) + .Set("privileged", true) + .Set("created", new DateTime(2011, 8, 23)) + .Build(); +``` +Transforms into: +```json +{ + "context": [ + { + "key": "role", + "value": "user" + }, + { + "value": "age", + "key": "22" + }, + { + "value": "privileged", + "key": "true" + }, + { + "key": "created", + "value": "2011-08-23T00:00:00.0000000" + } + ] +} +``` + +### Set entityId +To pass the [`entityId`](https://docs.flipt.io/concepts#entities) you need to specify a key `FliptProviderConfiguration.TargetingKey`. The value in `EvaluationContext` assigned to this key will be used as the `entityId` request parameter and will not be mapped into context. The value **must** be of string type. + +For example: +```csharp +var config = new FliptProviderConfiguration +{ + ServiceUri = new Uri("http://localhost:9000"), + Namespace = "default", + TargetingKey = "UserId" +}; +var context = EvaluationContext + .Builder() + .Set(config.TargetingKey, "some-user-id") + .Build(); +var isTestFlagEnabled = await client.GetBooleanValue("test-flag", false, context); +``` +Request: +```json +{ + "context": [], + "entity_id": "some-user-id", + "flag_key": "test-flag", + "namespace_key": "default", + "request_id": "" +} +``` + +### Set requestId +To pass the `requestId` you need to specify a key `FliptProviderConfiguration.RequestIdKey`. The value in `EvaluationContext` assigned to this key will be used as the `entityId` request parameter and will not be mapped into context. The value **must** be of string type. + +If the key is not specified in the configuration, or the context does not contain a value for this key, then `Activity.Current.Id` will be used if it present. + +For example: +```csharp +var config = new FliptProviderConfiguration +{ + ServiceUri = new Uri("http://localhost:9000"), + Namespace = "default", + TargetingKey = "UserId" +}; +var context = EvaluationContext + .Builder() + .Set(config.TargetingKey, "some-user-id"); +var isTestFlagEnabled = await client.GetBooleanValue("test-flag", false, context); +``` +Request: +```json +{ + "context": [], + "entity_id": "some-user-id", + "flag_key": "test-flag", + "namespace_key": "default", + "request_id": "" +} +``` + + + + diff --git a/src/OpenFeature.Contrib.Providers.Flipt/version.txt b/src/OpenFeature.Contrib.Providers.Flipt/version.txt new file mode 100644 index 00000000..8acdd82b --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.Flipt/version.txt @@ -0,0 +1 @@ +0.0.1 diff --git a/test/OpenFeature.Contrib.Providers.Flipt.Test/AttachmentParserTest.cs b/test/OpenFeature.Contrib.Providers.Flipt.Test/AttachmentParserTest.cs new file mode 100644 index 00000000..95d2cf2b --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.Flipt.Test/AttachmentParserTest.cs @@ -0,0 +1,236 @@ +using AutoFixture; +using FluentAssertions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Xunit; + +namespace OpenFeature.Contrib.Providers.Flipt.Test +{ + public class AttachmentParserTest + { + private readonly IFixture _fixture = new Fixture(); + + /// + /// DateTime test data + /// + /// Attachment | Expected value + public static IEnumerable DateTimeData + { + get + { + var fixture = new Fixture(); + var date = fixture.Create(); + yield return new object[] { $"\"{date:O}\"", date }; + yield return new object[] { $"\"{date.Date:O}\"", date.Date }; + } + } + + [Theory] + [InlineData("", null, false)] + [InlineData("\"value\"", "value", true)] + [InlineData("value", "value", true)] + public void TryParseString_ShouldBeExpectedResult(string attachment, string expectedValue, bool expectedResult) + { + // Act + var result = AttachmentParser.TryParseString(attachment, out var value); + + // Assert + result.Should().Be(expectedResult); + value.Should().Be(expectedValue); + } + + [Theory] + [InlineData("", 0, false)] + [InlineData("123.456", 123.456, true)] + [InlineData("-123.456", -123.456, true)] + [InlineData(".3", 0.3, true)] + [InlineData("1.2345E+2", 123.45, true)] + public void TryParseDouble_ShouldBeExpectedResult(string attachment, double expectedValue, bool expectedResult) + { + // Act + var result = AttachmentParser.TryParseDouble(attachment, out var value); + + // Assert + result.Should().Be(expectedResult); + value.Should().Be(expectedValue); + } + + [Theory] + [InlineData("", 0, false)] + [InlineData("-456", -456, true)] + [InlineData("-123", -123.456, true)] + [InlineData("0", 0, true)] + public void TryParseInteger_ShouldBeExpectedResult(string attachment, int expectedValue, bool expectedResult) + { + // Act + var result = AttachmentParser.TryParseInteger(attachment, out var value); + + // Assert + result.Should().Be(expectedResult); + value.Should().Be(expectedValue); + } + + [Theory] + [MemberData(nameof(DateTimeData))] + public void TryParseJsonValue_DateTimeValue_ShouldReturnTrue(string attachment, DateTime expectedValue) + { + // Act + var result = AttachmentParser.TryParseJsonValue(attachment, out var value); + + // Assert + result.Should().BeTrue(); + value.IsDateTime.Should().BeTrue(); + value.AsDateTime.Should().Be(expectedValue); + } + + [Fact] + public void TryParseJsonValue_DoubleValue_ShouldReturnTrue() + { + // Arrange + var value = 123.4; + var attachment = JsonSerializer.Serialize(value); + + // Act + var result = AttachmentParser.TryParseJsonValue(attachment, out var output); + + // Assert + result.Should().BeTrue(); + output.IsNumber.Should().BeTrue(); + output.AsDouble.Should().Be(value); + } + + [Fact] + public void TryParseJsonValue_IntegerValue_ShouldReturnTrue() + { + // Arrange + var value = _fixture.Create(); + var attachment = JsonSerializer.Serialize(value); + + // Act + var result = AttachmentParser.TryParseJsonValue(attachment, out var output); + + // Assert + result.Should().BeTrue(); + output.IsNumber.Should().BeTrue(); + output.AsInteger.Should().Be(value); + } + + [Theory] + [InlineData("true", true)] + [InlineData("false", false)] + public void TryParseJsonValue_BooleanValue_ShouldReturnTrue(string attachment, bool expectedValue) + { + // Act + var result = AttachmentParser.TryParseJsonValue(attachment, out var output); + + // Assert + result.Should().BeTrue(); + output.IsBoolean.Should().BeTrue(); + output.AsBoolean.Should().Be(expectedValue); + } + + [Fact] + public void TryParseJsonValue_ObjectValue_ShouldReturnFalse() + { + // Arrange + var value = new + { + booleanValue = _fixture.Create(), + integerValue = _fixture.Create(), + doubleValue = _fixture.Create(), + stringValue = _fixture.Create(), + dateTimeValue = _fixture.Create(), + nullValue = default(object), + nested = new + { + stringValue = _fixture.Create() + } + }; + + var attachment = JsonSerializer.Serialize(value); + + // Act + var result = AttachmentParser.TryParseJsonValue(attachment, out var output); + + // Assert + result.Should().BeTrue(); + output.IsStructure.Should().BeTrue(); + + output.AsStructure[nameof(value.booleanValue)].IsBoolean.Should().BeTrue(); + output.AsStructure[nameof(value.booleanValue)].AsBoolean.Should().Be(value.booleanValue); + + output.AsStructure[nameof(value.integerValue)].IsNumber.Should().BeTrue(); + output.AsStructure[nameof(value.integerValue)].AsInteger.Should().Be(value.integerValue); + + output.AsStructure[nameof(value.doubleValue)].IsNumber.Should().BeTrue(); + output.AsStructure[nameof(value.doubleValue)].AsDouble.Should().Be(value.doubleValue); + + output.AsStructure[nameof(value.stringValue)].IsString.Should().BeTrue(); + output.AsStructure[nameof(value.stringValue)].IsString.Should().BeTrue(); + + output.AsStructure[nameof(value.dateTimeValue)].IsDateTime.Should().BeTrue(); + output.AsStructure[nameof(value.dateTimeValue)].AsDateTime.Should().Be(value.dateTimeValue); + + output.AsStructure[nameof(value.nullValue)].IsNull.Should().BeTrue(); + + output.AsStructure[nameof(value.nested)].IsStructure.Should().BeTrue(); + output.AsStructure[nameof(value.nested)].AsStructure[nameof(value.nested.stringValue)].IsString.Should().BeTrue(); + output.AsStructure[nameof(value.nested)].AsStructure[nameof(value.nested.stringValue)].AsString.Should().Be(value.nested.stringValue); + } + + [Fact] + public void TryParseJsonValue_PlainArrayValue_ShouldReturnTrue() + { + // Arrange + var value = _fixture.CreateMany(); + var attachment = JsonSerializer.Serialize(value); + + // Act + var result = AttachmentParser.TryParseJsonValue(attachment, out var output); + + // Assert + result.Should().BeTrue(); + output.IsList.Should().BeTrue(); + output.AsList.Select(s => s.AsString).Should().BeEquivalentTo(value); + } + + [Fact] + public void TryParseJsonValue_ObjectArrayValue_ShouldReturnTrue() + { + // Arrange + var value = Enumerable.Range(0, 10).Select(i => + new + { + booleanValue = _fixture.Create(), + integerValue = _fixture.Create(), + stringValue = _fixture.Create(), + }); + + var attachment = JsonSerializer.Serialize(value); + + // Act + var result = AttachmentParser.TryParseJsonValue(attachment, out var output); + + // Assert + result.Should().BeTrue(); + output.IsList.Should().BeTrue(); + output.AsList.Should().OnlyContain(v => v.IsStructure); + } + + [Fact] + public void TryParseJsonValue_NullValue_ShouldReturnTrue() + { + // Arrange + var attachment = "null"; + + // Act + var result = AttachmentParser.TryParseJsonValue(attachment, out var output); + + // Assert + result.Should().BeTrue(); + output.IsNull.Should().BeTrue(); + } + } +} diff --git a/test/OpenFeature.Contrib.Providers.Flipt.Test/FliptConverterTest.cs b/test/OpenFeature.Contrib.Providers.Flipt.Test/FliptConverterTest.cs new file mode 100644 index 00000000..3b35fe4d --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.Flipt.Test/FliptConverterTest.cs @@ -0,0 +1,323 @@ +using AutoFixture; +using Flipt.Evaluation; +using FluentAssertions; +using OpenFeature.Constant; +using OpenFeature.Model; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Xunit; + +namespace OpenFeature.Contrib.Providers.Flipt.Test +{ + public class FliptConverterTest + { + private readonly IFixture _fixture = new Fixture(); + + public static IEnumerable ReasonTestData => + [ + [EvaluationReason.UnknownEvaluationReason, Reason.Unknown], + [EvaluationReason.FlagDisabledEvaluationReason, Reason.Disabled], + [EvaluationReason.MatchEvaluationReason, Reason.TargetingMatch], + [EvaluationReason.DefaultEvaluationReason, Reason.Default] + ]; + + public static IEnumerable EmptyContextData => + [ + [null], + [EvaluationContext.Empty] + ]; + + [Theory] + [MemberData(nameof(ReasonTestData))] + public void ConvertReason_ShouldReturnExpectedValue(EvaluationReason fliptReason, string expected) + { + // Act + var result = FliptConverter.ConvertReason(fliptReason); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [MemberData(nameof(EmptyContextData))] + public void CreateRequest_EmptyContext_ShouldCreateRequestWithoutContext(EvaluationContext context) + { + // Assert + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.Context.Should().BeEmpty(); + result.NamespaceKey.Should().Be(config.Namespace); + result.FlagKey.Should().Be(flagKey); + } + + [Fact] + public void CreateRequest_ContextHasStructureValue_ShouldIgnoreStructureValue() + { + // Arrange + var flagKey = _fixture.Create(); + var valueKey = _fixture.Create(); + var config = _fixture.Create(); + var structureValue = Structure + .Builder() + .Set(_fixture.Create(), _fixture.Create()) + .Build(); + var context = EvaluationContext + .Builder() + .Set(valueKey, structureValue) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.Context.Should().NotContain(v => v.Key == valueKey); + } + + [Fact] + public void CreateRequest_ContextHasEmptyValue_ShouldIgnoreEmptyValue() + { + // Arrange + var flagKey = _fixture.Create(); + var valueKey = _fixture.Create(); + var config = _fixture.Create(); + var emptyValue = new Value(); + var context = EvaluationContext + .Builder() + .Set(valueKey, emptyValue) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.Context.Should().NotContain(v => v.Key == valueKey); + } + + [Fact] + public void CreateRequest_ContextHasListValue_ShouldIgnoreListValue() + { + // Arrange + var flagKey = _fixture.Create(); + var valueKey = _fixture.Create(); + var config = _fixture.Create(); + var listValue = new Value(_fixture + .CreateMany() + .Select(v => new Value(v)) + .ToList()); + var context = EvaluationContext + .Builder() + .Set(valueKey, listValue) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.Context.Should().NotContain(v => v.Key == valueKey); + } + + [Fact] + public void CreateRequest_HasNonStringTargetingKeyValue_ShouldNotSetEntityId() + { + // Arrange + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + var entityId = _fixture.Create(); + var context = EvaluationContext + .Builder() + .Set(config.TargetingKey, entityId) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.EntityId.Should().BeEmpty(); + } + + [Fact] + public void CreateRequest_HasStringTargetingKeyValue_ShouldSetEntityId() + { + // Arrange + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + var entityId = _fixture.Create(); + var context = EvaluationContext + .Builder() + .Set(config.TargetingKey, entityId) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.EntityId.Should().Be(entityId); + } + + [Fact] + public void CreateRequest_HasNonStringRequestIdValue_ShouldNotSetRequestId() + { + // Arrange + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + var requestId = _fixture.Create(); + var context = EvaluationContext + .Builder() + .Set(config.RequestIdKey, requestId) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.RequestId.Should().BeEmpty(); + } + + [Fact] + public void CreateRequest_HasStringRequestIdValue_ShouldSetRequestId() + { + // Arrange + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + var requestId = _fixture.Create(); + var context = EvaluationContext + .Builder() + .Set(config.RequestIdKey, requestId) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.RequestId.Should().Be(requestId); + } + + [Fact] + public void CreateRequest_StringValue_ShouldIncludeInRequestContnext() + { + // Arrange + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + var valueKey = _fixture.Create(); + var stringValue = _fixture.Create(); + var context = EvaluationContext + .Builder() + .Set(valueKey, stringValue) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.Context.Should().Contain(v => v.Key == valueKey && v.Value == stringValue); + } + + [Fact] + public void CreateRequest_BooleanValue_ShouldIncludeInRequestContnext() + { + // Arrange + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + var key = _fixture.Create(); + var value = _fixture.Create(); + var context = EvaluationContext + .Builder() + .Set(key, value) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.Context.Should().Contain(v => v.Key == key && v.Value == value.ToString()); + } + + [Fact] + public void CreateRequest_NumberValue_ShouldIncludeInRequestContnext() + { + // Arrange + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + var key = _fixture.Create(); + var value = _fixture.Create(); + var context = EvaluationContext + .Builder() + .Set(key, value) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.Context.Should().Contain(v => v.Key == key && v.Value == value.ToString()); + } + + [Fact] + public void CreateRequest_DateTimeValue_ShouldIncludeInRequestContnext() + { + // Arrange + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + var key = _fixture.Create(); + var value = _fixture.Create(); + var context = EvaluationContext + .Builder() + .Set(key, value) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.Context.Should().Contain(v => v.Key == key && v.Value == value.ToString("o")); + } + + [Fact] + public void CreateRequest_HasRequestIdAndHasActivity_ShouldIgnoreActivity() + { + // Arrange + var activity = new Activity(_fixture.Create()); + activity.Start(); + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + var requestId = _fixture.Create(); + var context = EvaluationContext + .Builder() + .Set(config.RequestIdKey, requestId) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.RequestId.Should().Be(requestId); + } + + [Fact] + public void CreateRequest_HasNotRequestIdAndHasActivity_ShouldSetRequestIdAsActivityId() + { + // Arrange + var activity = new Activity(_fixture.Create()); + activity.Start(); + var flagKey = _fixture.Create(); + var config = _fixture.Create(); + var context = EvaluationContext + .Builder() + .Set(_fixture.Create(), _fixture.Create()) + .Build(); + + // Act + var result = FliptConverter.CreateRequest(flagKey, context, config); + + // Assert + result.RequestId.Should().Be(activity.Id); + } + } +} 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..ab1d5bd3 --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.Flipt.Test/FliptProviderTest.cs @@ -0,0 +1,274 @@ +using AutoFixture; +using Flipt.Evaluation; +using FluentAssertions; +using Grpc.Core; +using NSubstitute; +using OpenFeature.Error; +using System; +using System.Threading.Tasks; +using Xunit; +using static Flipt.Evaluation.EvaluationService; + +namespace OpenFeature.Contrib.Providers.Flipt.Test +{ + public class FliptProviderTest + { + private readonly IFixture _fixture = new Fixture(); + + [Fact] + public async Task SendRequest_NotFoundRpcException_ShouldThrowFlagNotFoundExceptionAsync() + { + // Arrange + var errorDetail = _fixture.Create(); + var request = _fixture.Create(); + var exception = new RpcException(new Status(StatusCode.NotFound, errorDetail)); + var sendRequestDelegate = Substitute.For>(); + sendRequestDelegate + .When(x => x.Invoke(request)) + .Throw(exception); + + // Act + var act = () => FliptProvider.SendRequestAsync(sendRequestDelegate, request); + + // Assert + await act.Should().ThrowAsync().WithMessage(errorDetail); + } + + [Fact] + public async Task SendRequest_InvalidArgumentRpcException_ShouldThrowInvalidContextExceptionAsync() + { + // Arrange + var errorDetail = _fixture.Create(); + var request = _fixture.Create(); + var exception = new RpcException(new Status(StatusCode.InvalidArgument, errorDetail)); + var sendRequestDelegate = Substitute.For>(); + sendRequestDelegate + .When(x => x.Invoke(request)) + .Throw(exception); + + // Act + var act = () => FliptProvider.SendRequestAsync(sendRequestDelegate, request); + + // Assert + await act.Should().ThrowAsync().WithMessage(errorDetail); + } + + [Fact] + public async Task SendRequest_GeneralRpcException_ShouldThrowGeneralExceptionAsync() + { + // Arrange + var errorDetail = _fixture.Create(); + var request = _fixture.Create(); + var exception = new RpcException(new Status(StatusCode.Unavailable, errorDetail)); + var sendRequestDelegate = Substitute.For>(); + sendRequestDelegate + .When(x => x.Invoke(request)) + .Throw(exception); + + // Act + var act = () => FliptProvider.SendRequestAsync(sendRequestDelegate, request); + + // Assert + await act.Should().ThrowAsync().WithMessage(exception.Message); + } + + [Fact] + public async Task SendRequest_GeneralException_ShouldThrowGeneralExceptionAsync() + { + // Arrange + var request = _fixture.Create(); + var exception = new Exception(); + var sendRequestDelegate = Substitute.For>(); + sendRequestDelegate + .When(x => x.Invoke(request)) + .Throw(exception); + + // Act + var act = () => FliptProvider.SendRequestAsync(sendRequestDelegate, request); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ResolveBooleanValue_VariableEvaluationDoesNotMatch_ShouldReturnExpectedValue() + { + // Arrange + var config = _fixture + .Build() + .With(b => b.UseBooleanEvaluation, false) + .Create(); + var flagKey = _fixture.Create(); + var defaultValue = _fixture.Create(); + var channel = Substitute.For(config.ServiceUri.OriginalString); + var client = Substitute.For(channel); + var response = _fixture + .Build() + .With(b => b.Match, false) + .Create(); + client + .VariantAsync(Arg.Any()) + .Returns(new AsyncUnaryCall(Task.FromResult(response), null, null, null, null)); + + var provider = new FliptProvider(client, config); + + // Act + var result = await provider.ResolveBooleanValue(flagKey, defaultValue); + + // Assert + result.FlagKey.Should().Be(response.FlagKey); + result.Value.Should().Be(defaultValue); + result.Variant.Should().BeNull(); + result.Reason.Should().Be(FliptConverter.ConvertReason(response.Reason)); + } + + [Fact] + public async Task ResolveBooleanValue_VariableEvaluationMatched_ShouldReturnExpectedValue() + { + // Arrange + var config = _fixture + .Build() + .With(b => b.UseBooleanEvaluation, false) + .Create(); + var flagKey = _fixture.Create(); + var defaultValue = _fixture.Create(); + var value = _fixture.Create(); + var attachment = System.Text.Json.JsonSerializer.Serialize(value); + var channel = Substitute.For(config.ServiceUri.OriginalString); + var client = Substitute.For(channel); + var response = _fixture + .Build() + .With(b => b.Match, true) + .With(b => b.VariantAttachment, attachment) + .Create(); + client + .VariantAsync(Arg.Any()) + .Returns(new AsyncUnaryCall(Task.FromResult(response), null, null, null, null)); + + var provider = new FliptProvider(client, config); + + // Act + var result = await provider.ResolveBooleanValue(flagKey, defaultValue); + + // Assert + result.FlagKey.Should().Be(response.FlagKey); + result.Value.Should().Be(value); + result.Reason.Should().Be(FliptConverter.ConvertReason(response.Reason)); + } + + [Fact] + public async Task ResolveBooleanValue_BooleanEvaluation_ShouldReturnExpectedValue() + { + // Arrange + var config = _fixture + .Build() + .With(b => b.UseBooleanEvaluation, true) + .Create(); + var flagKey = _fixture.Create(); + var defaultValue = _fixture.Create(); + var channel = Substitute.For(config.ServiceUri.OriginalString); + var client = Substitute.For(channel); + var response = _fixture.Create(); + client + .BooleanAsync(Arg.Any()) + .Returns(new AsyncUnaryCall(Task.FromResult(response), null, null, null, null)); + + var provider = new FliptProvider(client, config); + + // Act + var result = await provider.ResolveBooleanValue(flagKey, defaultValue); + + // Assert + result.Value.Should().Be(response.Enabled); + result.FlagKey.Should().Be(response.FlagKey); + result.Reason.Should().Be(FliptConverter.ConvertReason(response.Reason)); + } + + [Fact] + public async Task ResolveBooleanValue_VariableEvaluationCanNotParseAttachment_ShouldReturnExpectedValue() + { + // Arrange + var flagKey = _fixture.Create(); + var defaultValue = _fixture.Create(); + var value = _fixture.Create(); + var attachment = System.Text.Json.JsonSerializer.Serialize(value); + var provider = CreateVariantProvider(attachment); + + // Act + var act = () => provider.ResolveBooleanValue(flagKey, defaultValue); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ResolveDoubleValue_Matched_ShouldReturnExpectedValue() + { + // Arrange + var flagKey = _fixture.Create(); + var defaultValue = _fixture.Create(); + var value = _fixture.Create(); + var attachment = System.Text.Json.JsonSerializer.Serialize(value); + var provider = CreateVariantProvider(attachment); + + // Act + var result = await provider.ResolveDoubleValue(flagKey, defaultValue); + + // Assert + result.Value.Should().Be(value); + } + + [Fact] + public async Task ResolveIntegerValue_Matched_ShouldReturnExpectedValue() + { + // Arrange + var flagKey = _fixture.Create(); + var defaultValue = _fixture.Create(); + var value = _fixture.Create(); + var attachment = System.Text.Json.JsonSerializer.Serialize(value); + var provider = CreateVariantProvider(attachment); + + // Act + var result = await provider.ResolveDoubleValue(flagKey, defaultValue); + + // Assert + result.Value.Should().Be(value); + } + + [Fact] + public async Task ResolveStringValue_Matched_ShouldReturnExpectedValue() + { + // Arrange + var flagKey = _fixture.Create(); + var defaultValue = _fixture.Create(); + var value = _fixture.Create(); + var attachment = System.Text.Json.JsonSerializer.Serialize(value); + var provider = CreateVariantProvider(attachment); + + // Act + var result = await provider.ResolveStringValue(flagKey, defaultValue); + + // Assert + result.Value.Should().Be(value); + } + + private FliptProvider CreateVariantProvider(string attachment, bool match = true) + { + var config = _fixture + .Build() + .With(b => b.UseBooleanEvaluation, false) + .Create(); + var channel = Substitute.For(config.ServiceUri.OriginalString); + var client = Substitute.For(channel); + var response = _fixture + .Build() + .With(b => b.Match, match) + .With(b => b.VariantAttachment, attachment) + .Create(); + client + .VariantAsync(Arg.Any()) + .Returns(new AsyncUnaryCall(Task.FromResult(response), null, null, null, null)); + return new FliptProvider(client, config); + } + } +} 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..29878db4 --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.Flipt.Test/OpenFeature.Contrib.Providers.Flipt.Test.csproj @@ -0,0 +1,11 @@ + + + + latest + + + + + + +