From 964cf3297d1b78954d5139750d26acbad9fcd895 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Thu, 1 Dec 2022 19:00:46 +0100 Subject: [PATCH] feat: GO Feature Flag dotnet provider (#24) GO Feature Flag dotnet provider Signed-off-by: Thomas Poignant --- DotnetSdkContrib.sln | 14 + release-please-config.json | 10 + .../GOFeatureFlagRequest.cs | 19 + .../GoFeatureFlagProvider.cs | 326 ++++++++++++ .../GoFeatureFlagProviderOptions.cs | 29 + .../GoFeatureFlagResponse.cs | 43 ++ .../GoFeatureFlagUser.cs | 52 ++ ...ure.Contrib.Providers.GOFeatureFlag.csproj | 23 + .../README.md | 93 ++++ .../exception/FlagDisabled.cs | 9 + .../exception/FlagNotFoundError.cs | 22 + .../exception/GeneralError.cs | 22 + .../exception/GoFeatureFlagException.cs | 36 ++ .../exception/ImpossibleToConvertTypeError.cs | 23 + .../exception/InvalidEvaluationContext.cs | 22 + .../exception/InvalidOption.cs | 16 + .../exception/InvalidTargetingKey.cs | 22 + .../exception/TypeMismatchError.cs | 22 + .../version.txt | 1 + .../GoFeatureFlagProviderTest.cs | 503 ++++++++++++++++++ .../HttpClientMock.cs | 34 ++ ...ontrib.Providers.GOFeatureFlag.Test.csproj | 11 + 22 files changed, 1352 insertions(+) create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/GOFeatureFlagRequest.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProvider.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProviderOptions.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagResponse.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagUser.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/OpenFeature.Contrib.Providers.GOFeatureFlag.csproj create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/README.md create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/FlagDisabled.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/FlagNotFoundError.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/GeneralError.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/GoFeatureFlagException.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/ImpossibleToConvertTypeError.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidEvaluationContext.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidOption.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidTargetingKey.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/TypeMismatchError.cs create mode 100644 src/OpenFeature.Contrib.Providers.GOFeatureFlag/version.txt create mode 100644 test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/GoFeatureFlagProviderTest.cs create mode 100644 test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/HttpClientMock.cs create mode 100644 test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test.csproj diff --git a/DotnetSdkContrib.sln b/DotnetSdkContrib.sln index d2a98ef6..dea93120 100644 --- a/DotnetSdkContrib.sln +++ b/DotnetSdkContrib.sln @@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Hooks.O EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Flagd.Test", "test\OpenFeature.Contrib.Providers.Flagd.Test\OpenFeature.Contrib.Providers.Flagd.Test.csproj", "{206323A0-7334-4723-8394-C31C150B95DC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.GOFeatureFlag", "src\OpenFeature.Contrib.Providers.GOFeatureFlag\OpenFeature.Contrib.Providers.GOFeatureFlag.csproj", "{F7BE205B-0375-4EC5-9B18-FAFEF7A78D71}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.GOFeatureFlag.Test", "test\OpenFeature.Contrib.Providers.GOFeatureFlag.Test\OpenFeature.Contrib.Providers.GOFeatureFlag.Test.csproj", "{4041B63F-9CF6-4886-8FC7-BD1A7E45F859}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -40,11 +44,21 @@ Global {206323A0-7334-4723-8394-C31C150B95DC}.Debug|Any CPU.Build.0 = Debug|Any CPU {206323A0-7334-4723-8394-C31C150B95DC}.Release|Any CPU.ActiveCfg = Release|Any CPU {206323A0-7334-4723-8394-C31C150B95DC}.Release|Any CPU.Build.0 = Release|Any CPU + {F7BE205B-0375-4EC5-9B18-FAFEF7A78D71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7BE205B-0375-4EC5-9B18-FAFEF7A78D71}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7BE205B-0375-4EC5-9B18-FAFEF7A78D71}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7BE205B-0375-4EC5-9B18-FAFEF7A78D71}.Release|Any CPU.Build.0 = Release|Any CPU + {4041B63F-9CF6-4886-8FC7-BD1A7E45F859}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4041B63F-9CF6-4886-8FC7-BD1A7E45F859}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4041B63F-9CF6-4886-8FC7-BD1A7E45F859}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4041B63F-9CF6-4886-8FC7-BD1A7E45F859}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {6F8FF25A-F22B-4083-B3F9-B4B9BB6FB699} = {0E563821-BD08-4B7F-BF9D-395CAD80F026} {82D10BAE-F1EE-432A-BD5D-DECAD07A84FE} = {0E563821-BD08-4B7F-BF9D-395CAD80F026} {199FA48A-06EF-4E15-8206-C095D1455A99} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE} {206323A0-7334-4723-8394-C31C150B95DC} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE} + {F7BE205B-0375-4EC5-9B18-FAFEF7A78D71} = {0E563821-BD08-4B7F-BF9D-395CAD80F026} + {4041B63F-9CF6-4886-8FC7-BD1A7E45F859} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE} EndGlobalSection EndGlobal diff --git a/release-please-config.json b/release-please-config.json index 905a9667..f2cf5804 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -20,6 +20,16 @@ "extra-files": [ "OpenFeature.Contrib.Providers.Flagd.csproj" ] + }, + "src/OpenFeature.Contrib.Providers.GOFeatureFlag": { + "package-name": "OpenFeature.Contrib.Providers.GOFeatureFlag", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "OpenFeature.Contrib.Providers.GOFeatureFlag.csproj" + ] } } } \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GOFeatureFlagRequest.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GOFeatureFlagRequest.cs new file mode 100644 index 00000000..b9178d54 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GOFeatureFlagRequest.cs @@ -0,0 +1,19 @@ +namespace OpenFeature.Contrib.Providers.GOFeatureFlag +{ + /// + /// GOFeatureFlagRequest is the object formatting the request to the relay proxy. + /// + /// Type of the default value. + public class GOFeatureFlagRequest + { + /// + /// GoFeatureFlagUser is the representation of the user. + /// + public GoFeatureFlagUser User { get; set; } + + /// + /// default value if we have an error. + /// + public T DefaultValue { get; set; } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProvider.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProvider.cs new file mode 100644 index 00000000..ceeb7e96 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProvider.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using OpenFeature.Constant; +using OpenFeature.Contrib.Providers.GOFeatureFlag.exception; +using OpenFeature.Model; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag +{ + /// + /// GoFeatureFlagProvider is the OpenFeature provider for GO Feature Flag. + /// + public class GoFeatureFlagProvider : FeatureProvider + { + private const string ApplicationJson = "application/json"; + private HttpClient _httpClient; + private JsonSerializerOptions _serializerOptions; + + /// + /// Constructor of the provider. + /// Options used while creating the provider + /// if no options are provided or we have a wrong configuration. + /// + public GoFeatureFlagProvider(GoFeatureFlagProviderOptions options) + { + ValidateInputOptions(options); + InitializeProvider(options); + } + + /// + /// validateInputOptions is validating the different options provided when creating the provider. + /// + /// Options used while creating the provider + /// if no options are provided or we have a wrong configuration. + private void ValidateInputOptions(GoFeatureFlagProviderOptions options) + { + if (options is null) throw new InvalidOption("No options provided"); + + if (string.IsNullOrEmpty(options.Endpoint)) + throw new InvalidOption("endpoint is a mandatory field when initializing the provider"); + } + + /// + /// initializeProvider is initializing the different class element used by the provider. + /// + /// Options used while creating the provider + private void InitializeProvider(GoFeatureFlagProviderOptions options) + { + _httpClient = options.HttpMessageHandler != null + ? new HttpClient(options.HttpMessageHandler) + : new HttpClient + { + Timeout = options.Timeout.Ticks.Equals(0) + ? new TimeSpan(10000 * TimeSpan.TicksPerMillisecond) + : options.Timeout + }; + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(ApplicationJson)); + _httpClient.BaseAddress = new Uri(options.Endpoint); + _serializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + } + + /// + /// Return the metadata associated to this provider. + /// + public override Metadata GetMetadata() + { + return new Metadata("GO Feature Flag Provider"); + } + + /// + /// ResolveBooleanValue resolve the value for a Boolean Flag. + /// + /// Name of the flag + /// Default value used in case of error. + /// Context about the user + /// A ResolutionDetails object containing the value of your flag + /// If the type of the flag does not match + /// If the flag does not exists + /// If an unknown error happen + /// If the flag is disabled + public override async Task> ResolveBooleanValue(string flagKey, bool defaultValue, + EvaluationContext context = null) + { + try + { + var resp = await CallApi(flagKey, defaultValue, context); + return new ResolutionDetails(flagKey, bool.Parse(resp.value.ToString()), ErrorType.None, + resp.reason, resp.variationType); + } + catch (FormatException e) + { + throw new TypeMismatchError($"flag value {flagKey} had unexpected type", e); + } + catch (FlagDisabled) + { + return new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Disabled); + } + } + + /// + /// ResolveBooleanValue resolve the value for a string Flag. + /// + /// Name of the flag + /// Default value used in case of error. + /// Context about the user + /// A ResolutionDetails object containing the value of your flag + /// If the type of the flag does not match + /// If the flag does not exists + /// If an unknown error happen + /// If the flag is disabled + public override async Task> ResolveStringValue(string flagKey, string defaultValue, + EvaluationContext context = null) + { + try + { + var resp = await CallApi(flagKey, defaultValue, context); + if (!(resp.value is JsonElement element && element.ValueKind == JsonValueKind.String)) + throw new TypeMismatchError($"flag value {flagKey} had unexpected type"); + return new ResolutionDetails(flagKey, resp.value.ToString(), ErrorType.None, resp.reason, + resp.variationType); + } + catch (FormatException e) + { + throw new TypeMismatchError($"flag value {flagKey} had unexpected type", e); + } + catch (FlagDisabled) + { + return new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Disabled); + } + } + + /// + /// ResolveBooleanValue resolve the value for an int Flag. + /// + /// Name of the flag + /// Default value used in case of error. + /// Context about the user + /// A ResolutionDetails object containing the value of your flag + /// If the type of the flag does not match + /// If the flag does not exists + /// If an unknown error happen + /// If the flag is disabled + public override async Task> ResolveIntegerValue(string flagKey, int defaultValue, + EvaluationContext context = null) + { + try + { + var resp = await CallApi(flagKey, defaultValue, context); + return new ResolutionDetails(flagKey, int.Parse(resp.value.ToString()), ErrorType.None, + resp.reason, resp.variationType); + } + catch (FormatException e) + { + throw new TypeMismatchError($"flag value {flagKey} had unexpected type", e); + } + catch (FlagDisabled) + { + return new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Disabled); + } + } + + /// + /// ResolveBooleanValue resolve the value for a double Flag. + /// + /// Name of the flag + /// Default value used in case of error. + /// Context about the user + /// A ResolutionDetails object containing the value of your flag + /// If the type of the flag does not match + /// If the flag does not exists + /// If an unknown error happen + /// If the flag is disabled + public override async Task> ResolveDoubleValue(string flagKey, double defaultValue, + EvaluationContext context = null) + { + try + { + var resp = await CallApi(flagKey, defaultValue, context); + return new ResolutionDetails(flagKey, double.Parse(resp.value.ToString()), ErrorType.None, + resp.reason, resp.variationType); + } + catch (FormatException e) + { + throw new TypeMismatchError($"flag value {flagKey} had unexpected type", e); + } + catch (FlagDisabled) + { + return new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Disabled); + } + } + + /// + /// ResolveBooleanValue resolve the value for a Boolean Flag. + /// + /// Name of the flag + /// Default value used in case of error. + /// Context about the user + /// A ResolutionDetails object containing the value of your flag + /// If the type of the flag does not match + /// If the flag does not exists + /// If an unknown error happen + /// If the flag is disabled + public override async Task> ResolveStructureValue(string flagKey, Value defaultValue, + EvaluationContext context = null) + { + try + { + var resp = await CallApi(flagKey, defaultValue, context); + if (resp.value is JsonElement) + { + var value = ConvertValue((JsonElement)resp.value); + return new ResolutionDetails(flagKey, value, ErrorType.None, resp.reason, + resp.variationType); + } + + throw new TypeMismatchError($"flag value {flagKey} had unexpected type"); + } + catch (FormatException e) + { + throw new TypeMismatchError($"flag value {flagKey} had unexpected type", e); + } + catch (FlagDisabled) + { + return new ResolutionDetails(flagKey, defaultValue, ErrorType.None, Reason.Disabled); + } + } + + /// + /// This method is handling the call to the GO Feature Flag Relay proxy. + /// + /// Name of the flag + /// Default value + /// EvaluationContext to convert as parameters for GO Feature Flag Relay Proxy + /// Type of the data we should retrieve + /// The API response in a GoFeatureFlagResponse object. + /// If the flag does not exists + /// If an unknown error happen + /// If the flag is disabled + private async Task CallApi(string flagKey, T defaultValue, + EvaluationContext context = null) + { + var user = GoFeatureFlagUser.FromEvaluationContext(context); + var request = new GOFeatureFlagRequest + { + User = user, + DefaultValue = defaultValue + }; + var goffRequest = JsonSerializer.Serialize(request, _serializerOptions); + + var response = await _httpClient.PostAsync($"v1/feature/{flagKey}/eval", + new StringContent(goffRequest, Encoding.UTF8, ApplicationJson)); + + if (response.StatusCode == HttpStatusCode.NotFound) + throw new FlagNotFoundError($"flag {flagKey} was not found in your configuration"); + + if (response.StatusCode >= HttpStatusCode.BadRequest) + throw new GeneralError("impossible to contact GO Feature Flag relay proxy instance"); + + var responseBody = await response.Content.ReadAsStringAsync(); + var goffResp = + JsonSerializer.Deserialize(responseBody); + + if (goffResp != null && Reason.Disabled.Equals(goffResp.reason)) + throw new FlagDisabled(); + + if (ErrorType.FlagNotFound.ToString().Equals(goffResp.errorCode)) + throw new FlagNotFoundError($"flag {flagKey} was not found in your configuration"); + + return goffResp; + } + + /// + /// convertValue is converting the object return by the proxy response in the right type. + /// + /// The value we have received + /// A converted object + /// If we are not able to convert the data. + private Value ConvertValue(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Null || value.ValueKind == JsonValueKind.Undefined) return null; + + if (value.ValueKind == JsonValueKind.False || value.ValueKind == JsonValueKind.True) + return new Value(value.GetBoolean()); + + if (value.ValueKind == JsonValueKind.Number) return new Value(value.GetDouble()); + + if (value.ValueKind == JsonValueKind.Object) + { + var dict = new Dictionary(); + using var objEnumerator = value.EnumerateObject(); + while (objEnumerator.MoveNext()) + { + var current = objEnumerator.Current; + var currentValue = ConvertValue(current.Value); + if (currentValue != null) dict.Add(current.Name, ConvertValue(current.Value)); + } + + return new Value(new Structure(dict)); + } + + if (value.ValueKind == JsonValueKind.String) return new Value(value.ToString()); + + if (value.ValueKind == JsonValueKind.Array) + { + using var arrayEnumerator = value.EnumerateArray(); + var arr = new List(); + + while (arrayEnumerator.MoveNext()) + { + var current = arrayEnumerator.Current; + var convertedValue = ConvertValue(current); + if (convertedValue != null) arr.Add(convertedValue); + } + + return new Value(arr); + } + + throw new ImpossibleToConvertTypeError($"impossible to convert the object {value}"); + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProviderOptions.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProviderOptions.cs new file mode 100644 index 00000000..f89eb5d1 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProviderOptions.cs @@ -0,0 +1,29 @@ +using System; +using System.Net.Http; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag +{ + /// + /// GoFeatureFlagProviderOptions contains the options to initialise the provider. + /// + public class GoFeatureFlagProviderOptions + { + /// + /// (mandatory) endpoint contains the DNS of your GO Feature Flag relay proxy + /// example: https://mydomain.com/gofeatureflagproxy/ + /// + public string Endpoint { get; set; } + + /// + /// (optional) timeout we are waiting when calling the go-feature-flag relay proxy API. + /// Default: 10000 ms + /// + public TimeSpan Timeout { get; set; } = new TimeSpan(10000 * TimeSpan.TicksPerMillisecond); + + /// + /// (optional) If you want to provide your own HttpMessageHandler. + /// Default: null + /// + public HttpMessageHandler HttpMessageHandler { get; set; } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagResponse.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagResponse.cs new file mode 100644 index 00000000..c8de3dd6 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagResponse.cs @@ -0,0 +1,43 @@ +namespace OpenFeature.Contrib.Providers.GOFeatureFlag +{ + /// + /// GoFeatureFlagResponse is the response returned by the relay proxy. + /// + public class GoFeatureFlagResponse + { + /// + /// trackEvent is true when this call was tracked in GO Feature Flag. + /// + public bool trackEvents { get; set; } + + /// + /// variationType contains the name of the variation used for this flag. + /// + public string variationType { get; set; } + + /// + /// failed is true if GO Feature Flag had an issue. + /// + public bool failed { get; set; } + + /// + /// version of the flag used (optional) + /// + public string version { get; set; } + + /// + /// reason used to choose this variation. + /// + public string reason { get; set; } + + /// + /// errorCode is empty if everything went ok. + /// + public string errorCode { get; set; } + + /// + /// value contains the result of the flag. + /// + public object value { get; set; } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagUser.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagUser.cs new file mode 100644 index 00000000..25269fee --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagUser.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using OpenFeature.Contrib.Providers.GOFeatureFlag.exception; +using OpenFeature.Model; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag +{ + /// + /// GOFeatureFlagUser is the representation of a User inside GO Feature Flag. + /// + public class GoFeatureFlagUser + { + private const string AnonymousField = "anonymous"; + private const string KeyField = "targetingKey"; + private string Key { get; set; } + private bool Anonymous { get; set; } + private Dictionary Custom { get; set; } + + /** + * FromEvaluationContext convert the evaluation context into a GOFeatureFlagUser Object. + */ + public static GoFeatureFlagUser FromEvaluationContext(EvaluationContext ctx) + { + try + { + if (ctx is null) + throw new InvalidEvaluationContext("GO Feature Flag need an Evaluation context to work."); + if (!ctx.GetValue(KeyField).IsString) + throw new InvalidTargetingKey("targetingKey field MUST be a string."); + } + catch (KeyNotFoundException e) + { + throw new InvalidTargetingKey("targetingKey field is mandatory.", e); + } + + var anonymous = ctx.ContainsKey(AnonymousField) && ctx.GetValue(AnonymousField).IsBoolean + ? ctx.GetValue(AnonymousField).AsBoolean + : false; + + var custom = ctx.AsDictionary().ToDictionary(x => x.Key, x => x.Value.AsObject); + custom.Remove(AnonymousField); + custom.Remove(KeyField); + + return new GoFeatureFlagUser + { + Key = ctx.GetValue("targetingKey").AsString, + Anonymous = anonymous.Value, + Custom = custom + }; + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/OpenFeature.Contrib.Providers.GOFeatureFlag.csproj b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/OpenFeature.Contrib.Providers.GOFeatureFlag.csproj new file mode 100644 index 00000000..a4a67b66 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/OpenFeature.Contrib.Providers.GOFeatureFlag.csproj @@ -0,0 +1,23 @@ + + + + OpenFeature.Contrib.GOFeatureFlag + 0.1.0 + $(VersionNumber) + $(VersionNumber) + $(VersionNumber) + GO Feature Flag provider for .NET + https://gofeatureflag.org + https://github.com/open-feature/dotnet-sdk-contrib + Thomas Poignant + + + + + + + + 8.0 + + + diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/README.md b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/README.md new file mode 100644 index 00000000..38866c22 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/README.md @@ -0,0 +1,93 @@ +# GO Feature Flag .NET Provider + +GO Feature Flag provider allows you to connect to your GO Feature Flag instance. + +[GO Feature Flag](https://gofeatureflag.org) believes in simplicity and offers a simple and lightweight solution to use feature flags. +Our focus is to avoid any complex infrastructure work to use GO Feature Flag. + +This is a complete feature flagging solution with the possibility to target only a group of users, use any types of flags, store your configuration in various location and advanced rollout functionality. You can also collect usage data of your flags and be notified of configuration changes. + +# .Net SDK usage + +## Install dependencies + +The first things we will do is install the **Open Feature SDK** and the **GO Feature Flag provider**. + +### .NET Cli +```shell +dotnet add package OpenFeature.Contrib.Providers.GOFeatureFlag +``` +### Package Manager + +```shell +NuGet\Install-Package OpenFeature.Contrib.Providers.GOFeatureFlag +``` +### Package Reference + +```xml + +``` +### Packet cli + +```shell +paket add OpenFeature.Contrib.Providers.GOFeatureFlag +``` + +### Cake + +```shell +// Install OpenFeature.Contrib.Providers.GOFeatureFlag as a Cake Addin +#addin nuget:?package=OpenFeature.Contrib.Providers.GOFeatureFlag + +// Install OpenFeature.Contrib.Providers.GOFeatureFlag as a Cake Tool +#tool nuget:?package=OpenFeature.Contrib.Providers.GOFeatureFlag +``` + +## Initialize your Open Feature client + +To evaluate the flags you need to have an Open Feature configured in you app. +This code block shows you how you can create a client that you can use in your application. + +```csharp +using OpenFeature; +using OpenFeature.Contrib.Providers.GOFeatureFlag; + +// ... + +var goFeatureFlagProvider = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions +{ + Endpoint = "http://localhost:1031/", + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) +}); +Api.Instance.SetProvider(goFeatureFlagProvider); +var client = Api.Instance.GetClient("my-app"); +``` + +## Evaluate your flag + +This code block explain how you can create an `EvaluationContext` and use it to evaluate your flag. + + +> In this example we are evaluating a `boolean` flag, but other types are available. +> +> **Refer to the [Open Feature documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api#basic-evaluation) to know more about it.** + +```csharp +// Context of your flag evaluation. +// With GO Feature Flag you MUST have a targetingKey that is a unique identifier of the user. +var userContext = EvaluationContext.Builder() + .Set("targetingKey", "1d1b9238-2591-4a47-94cf-d2bc080892f1") // user unique identifier (mandatory) + .Set("firstname", "john") + .Set("lastname", "doe") + .Set("email", "john.doe@gofeatureflag.org") + .Set("admin", true) // this field is used in the targeting rule of the flag "flag-only-for-admin" + .Set("anonymous", false) + .Build(); + +var adminFlag = await client.GetBooleanValue("flag-only-for-admin", false, userContext); +if (adminFlag) { + // flag "flag-only-for-admin" is true for the user +} else { + // flag "flag-only-for-admin" is false for the user +} +``` diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/FlagDisabled.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/FlagDisabled.cs new file mode 100644 index 00000000..4a4fc4ab --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/FlagDisabled.cs @@ -0,0 +1,9 @@ +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.exception +{ + /// + /// Exception thrown when a flag is disabled + /// + public class FlagDisabled : GoFeatureFlagException + { + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/FlagNotFoundError.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/FlagNotFoundError.cs new file mode 100644 index 00000000..cedc9b72 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/FlagNotFoundError.cs @@ -0,0 +1,22 @@ +using System; +using OpenFeature.Constant; +using OpenFeature.Error; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.exception +{ + /// + /// Exception thrown when the flag is not found by GO Feature Flag relay proxy. + /// + public class FlagNotFoundError : FeatureProviderException + { + /// + /// Constructor of the exception + /// + /// Message to display + /// Original exception + public FlagNotFoundError(string message, Exception innerException = null) : base(ErrorType.FlagNotFound, + message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/GeneralError.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/GeneralError.cs new file mode 100644 index 00000000..479ffc8a --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/GeneralError.cs @@ -0,0 +1,22 @@ +using System; +using OpenFeature.Constant; +using OpenFeature.Error; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.exception +{ + /// + /// Exception throw when we don't have a specific case. + /// + public class GeneralError : FeatureProviderException + { + /// + /// Constructor of the exception + /// + /// Message to display + /// Original exception + public GeneralError(string message, Exception innerException = null) : base(ErrorType.General, message, + innerException) + { + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/GoFeatureFlagException.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/GoFeatureFlagException.cs new file mode 100644 index 00000000..dabe0832 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/GoFeatureFlagException.cs @@ -0,0 +1,36 @@ +using System; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.exception +{ + /// + /// GoFeatureFlagException is the root exception of GO Feature Flag provider. + /// + public abstract class GoFeatureFlagException : Exception + { + /// + /// Constructor + /// + public GoFeatureFlagException() + { + } + + /// + /// Constructor + /// + /// Message of your exception + public GoFeatureFlagException(string message) + : base(message) + { + } + + /// + /// Constructor + /// + /// Message of your exception + /// Root exception. + public GoFeatureFlagException(string message, Exception inner) + : base(message, inner) + { + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/ImpossibleToConvertTypeError.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/ImpossibleToConvertTypeError.cs new file mode 100644 index 00000000..d9e79259 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/ImpossibleToConvertTypeError.cs @@ -0,0 +1,23 @@ +using System; +using OpenFeature.Constant; +using OpenFeature.Error; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.exception +{ + /// + /// Exception throw when we have a type that we are not able to convert. + /// + public class ImpossibleToConvertTypeError : FeatureProviderException + { + /// + /// Constructor of the exception + /// + /// Message to display + /// Original exception + public ImpossibleToConvertTypeError(string message, Exception innerException = null) : base( + ErrorType.ParseError, + message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidEvaluationContext.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidEvaluationContext.cs new file mode 100644 index 00000000..a8a9202c --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidEvaluationContext.cs @@ -0,0 +1,22 @@ +using System; +using OpenFeature.Constant; +using OpenFeature.Error; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.exception +{ + /// + /// Exception throw when the Evaluation Context is invalid. + /// + public class InvalidEvaluationContext : FeatureProviderException + { + /// + /// Constructor of the exception + /// + /// Message to display + /// Original exception + public InvalidEvaluationContext(string message, Exception innerException = null) : base( + ErrorType.InvalidContext, message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidOption.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidOption.cs new file mode 100644 index 00000000..9e3d6ef4 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidOption.cs @@ -0,0 +1,16 @@ +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.exception +{ + /// + /// Exception throw when the options of the provider are invalid. + /// + public class InvalidOption : GoFeatureFlagException + { + /// + /// Constructor of the exception + /// + /// Message to display + public InvalidOption(string message) : base(message) + { + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidTargetingKey.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidTargetingKey.cs new file mode 100644 index 00000000..cda1da91 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/InvalidTargetingKey.cs @@ -0,0 +1,22 @@ +using System; +using OpenFeature.Constant; +using OpenFeature.Error; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.exception +{ + /// + /// Exception throw when The Evaluation Context does not contains a targetingKey field. + /// + public class InvalidTargetingKey : FeatureProviderException + { + /// + /// Constructor of the exception + /// + /// Message to display + /// Original exception + public InvalidTargetingKey(string message, Exception innerException = null) : base(ErrorType.InvalidContext, + message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/TypeMismatchError.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/TypeMismatchError.cs new file mode 100644 index 00000000..05d46754 --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/exception/TypeMismatchError.cs @@ -0,0 +1,22 @@ +using System; +using OpenFeature.Constant; +using OpenFeature.Error; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.exception +{ + /// + /// Exception throw when the type we received from GO Feature Flag is different than the one expected. + /// + public class TypeMismatchError : FeatureProviderException + { + /// + /// Constructor of the exception + /// + /// Message to display + /// Original exception + public TypeMismatchError(string message, Exception innerException = null) : base(ErrorType.TypeMismatch, + message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/version.txt b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/version.txt new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/version.txt @@ -0,0 +1 @@ +0.1.0 diff --git a/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/GoFeatureFlagProviderTest.cs b/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/GoFeatureFlagProviderTest.cs new file mode 100644 index 00000000..2e800a1d --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/GoFeatureFlagProviderTest.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using OpenFeature.Constant; +using OpenFeature.Contrib.Providers.GOFeatureFlag.exception; +using OpenFeature.Model; +using RichardSzalay.MockHttp; +using Xunit; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.Test; + +public class GoFeatureFlagProviderTest +{ + private static readonly string baseUrl = "http://gofeatureflag.org"; + private static readonly string prefixEval = baseUrl + "/v1/feature/"; + private static readonly string suffixEval = "/eval"; + private readonly EvaluationContext _defaultEvaluationCtx = InitDefaultEvaluationCtx(); + private readonly HttpMessageHandler _mockHttp = InitMock(); + + private static HttpMessageHandler InitMock() + { + const string mediaType = "application/json"; + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When($"{prefixEval}fail_500{suffixEval}").Respond(HttpStatusCode.InternalServerError); + mockHttp.When($"{prefixEval}flag_not_found{suffixEval}").Respond(HttpStatusCode.NotFound); + mockHttp.When($"{prefixEval}bool_targeting_match{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"True\",\"failed\":false,\"version\":\"\",\"reason\":\"TARGETING_MATCH\",\"errorCode\":\"\",\"value\":true}"); + mockHttp.When($"{prefixEval}disabled{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"defaultSdk\",\"failed\":false,\"version\":\"\",\"reason\":\"DISABLED\",\"errorCode\":\"\",\"value\":true}"); + mockHttp.When($"{prefixEval}disabled_double{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"defaultSdk\",\"failed\":false,\"version\":\"\",\"reason\":\"DISABLED\",\"errorCode\":\"\",\"value\":100.25}"); + mockHttp.When($"{prefixEval}disabled_integer{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"defaultSdk\",\"failed\":false,\"version\":\"\",\"reason\":\"DISABLED\",\"errorCode\":\"\",\"value\":100}"); + mockHttp.When($"{prefixEval}disabled_object{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"defaultSdk\",\"failed\":false,\"version\":\"\",\"reason\":\"DISABLED\",\"errorCode\":\"\",\"value\":null}"); + mockHttp.When($"{prefixEval}disabled_string{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"defaultSdk\",\"failed\":false,\"version\":\"\",\"reason\":\"DISABLED\",\"errorCode\":\"\",\"value\":\"\"}"); + mockHttp.When($"{prefixEval}double_key{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"True\",\"failed\":false,\"version\":\"\",\"reason\":\"TARGETING_MATCH\",\"errorCode\":\"\",\"value\":100.25}"); + mockHttp.When($"{prefixEval}flag_not_found{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"SdkDefault\",\"failed\":true,\"version\":\"\",\"reason\":\"ERROR\",\"errorCode\":\"FLAG_NOT_FOUND\",\"value\":\"false\"}"); + mockHttp.When($"{prefixEval}integer_key{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"True\",\"failed\":false,\"version\":\"\",\"reason\":\"TARGETING_MATCH\",\"errorCode\":\"\",\"value\":100}"); + mockHttp.When($"{prefixEval}list_key{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"True\",\"failed\":false,\"version\":\"\",\"reason\":\"TARGETING_MATCH\",\"errorCode\":\"\",\"value\":[\"test\",\"test1\",\"test2\",\"false\",\"test3\"]}"); + mockHttp.When($"{prefixEval}object_key{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"True\",\"failed\":false,\"version\":\"\",\"reason\":\"TARGETING_MATCH\",\"errorCode\":\"\",\"value\":{\"test\":\"test1\",\"test2\":false,\"test3\":123.3,\"test4\":1,\"test5\":null}}"); + mockHttp.When($"{prefixEval}string_key{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"True\",\"failed\":false,\"version\":\"\",\"reason\":\"TARGETING_MATCH\",\"errorCode\":\"\",\"value\":\"CC0000\"}"); + mockHttp.When($"{prefixEval}unknown_reason{suffixEval}").Respond(mediaType, + "{\"trackEvents\":true,\"variationType\":\"True\",\"failed\":false,\"version\":\"\",\"reason\":\"CUSTOM_REASON\",\"errorCode\":\"\",\"value\":true}"); + + return mockHttp; + } + + private static EvaluationContext InitDefaultEvaluationCtx() + { + return EvaluationContext.Builder() + .Set("targetingKey", "d45e303a-38c2-11ed-a261-0242ac120002") + .Set("email", "john.doe@gofeatureflag.org") + .Set("firstname", "john") + .Set("lastname", "doe") + .Set("anonymous", false) + .Set("professional", true) + .Set("rate", 3.14) + .Set("age", 30) + .Set("company_info", new Value(new Structure(new Dictionary + { + { "name", new Value("my_company") }, + { "size", new Value(120) } + }))) + .Set("labels", new Value(new List + { + new("pro"), + new("beta") + })) + .Build(); + } + + + [Fact] + private void getMetadata_validate_name() + { + var goFeatureFlagProvider = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Timeout = new TimeSpan(19 * TimeSpan.TicksPerHour), + Endpoint = baseUrl + }); + Api.Instance.SetProvider(goFeatureFlagProvider); + Assert.Equal("GO Feature Flag Provider", Api.Instance.GetProvider().GetMetadata().Name); + } + + + [Fact] + private void constructor_options_null() + { + Assert.Throws(() => new GoFeatureFlagProvider(null)); + } + + [Fact] + private void constructor_options_empty() + { + Assert.Throws(() => new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions())); + } + + [Fact] + private void constructor_options_empty_endpoint() + { + Assert.Throws( + () => new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions { Endpoint = "" })); + } + + [Fact] + private void constructor_options_only_timeout() + { + Assert.Throws( + () => new GoFeatureFlagProvider( + new GoFeatureFlagProviderOptions { Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) } + ) + ); + } + + [Fact] + private void constructor_options_valid_endpoint() + { + var exception = Record.Exception(() => + new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions { Endpoint = baseUrl })); + Assert.Null(exception); + } + + [Fact] + private void should_throw_an_error_if_endpoint_not_available() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetBooleanDetails("fail_500", false, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.False(res.Result.Value); + Assert.Equal(ErrorType.General, res.Result.ErrorType); + Assert.Equal(Reason.Error, res.Result.Reason); + } + + [Fact] + private void should_throw_an_error_if_flag_does_not_exists() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetBooleanDetails("flag_not_found", false, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.False(res.Result.Value); + Assert.Equal(ErrorType.FlagNotFound, res.Result.ErrorType); + Assert.Equal(Reason.Error, res.Result.Reason); + } + + [Fact] + private void should_throw_an_error_if_we_expect_a_boolean_and_got_another_type() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetBooleanDetails("string_key", false, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.False(res.Result.Value); + Assert.Equal(ErrorType.TypeMismatch, res.Result.ErrorType); + Assert.Equal(Reason.Error, res.Result.Reason); + } + + [Fact] + private void should_resolve_a_valid_boolean_flag_with_TARGETING_MATCH_reason() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetBooleanDetails("bool_targeting_match", false, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.True(res.Result.Value); + Assert.Equal(ErrorType.None, res.Result.ErrorType); + Assert.Equal(Reason.TargetingMatch, res.Result.Reason); + Assert.Equal("True", res.Result.Variant); + } + + [Fact] + private void should_return_custom_reason_if_returned_by_relay_proxy() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetBooleanDetails("unknown_reason", false, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.True(res.Result.Value); + Assert.Equal(ErrorType.None, res.Result.ErrorType); + Assert.Equal("CUSTOM_REASON", res.Result.Reason); + Assert.Equal("True", res.Result.Variant); + } + + [Fact] + private void should_use_boolean_default_value_if_the_flag_is_disabled() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetBooleanDetails("disabled", false, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.False(res.Result.Value); + Assert.Equal(Reason.Disabled, res.Result.Reason); + } + + [Fact] + private void should_throw_an_error_if_we_expect_a_string_and_got_another_type() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetStringDetails("bool_targeting_match", "default", _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal("default", res.Result.Value); + Assert.Equal(ErrorType.TypeMismatch, res.Result.ErrorType); + Assert.Equal(Reason.Error, res.Result.Reason); + } + + [Fact] + private void should_resolve_a_valid_string_flag_with_TARGETING_MATCH_reason() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetStringDetails("string_key", "defaultValue", _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal("CC0000", res.Result.Value); + Assert.Equal(ErrorType.None, res.Result.ErrorType); + Assert.Equal(Reason.TargetingMatch, res.Result.Reason); + Assert.Equal("True", res.Result.Variant); + } + + [Fact] + private void should_use_string_default_value_if_the_flag_is_disabled() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetStringDetails("disabled_string", "defaultValue", _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal("defaultValue", res.Result.Value); + Assert.Equal(Reason.Disabled, res.Result.Reason); + } + + [Fact] + private void should_throw_an_error_if_we_expect_a_integer_and_got_another_type() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetIntegerDetails("string_key", 200, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal(200, res.Result.Value); + Assert.Equal(ErrorType.TypeMismatch, res.Result.ErrorType); + Assert.Equal(Reason.Error, res.Result.Reason); + } + + [Fact] + private void should_resolve_a_valid_integer_flag_with_TARGETING_MATCH_reason() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetIntegerDetails("integer_key", 1200, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal(100, res.Result.Value); + Assert.Equal(ErrorType.None, res.Result.ErrorType); + Assert.Equal(Reason.TargetingMatch, res.Result.Reason); + Assert.Equal("True", res.Result.Variant); + } + + [Fact] + private void should_use_integer_default_value_if_the_flag_is_disabled() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetIntegerDetails("disabled_integer", 1225, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal(1225, res.Result.Value); + Assert.Equal(Reason.Disabled, res.Result.Reason); + } + + [Fact] + private void should_throw_an_error_if_we_expect_a_integer_and_double_type() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetIntegerDetails("double_key", 200, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal(200, res.Result.Value); + Assert.Equal(ErrorType.TypeMismatch, res.Result.ErrorType); + Assert.Equal(Reason.Error, res.Result.Reason); + } + + [Fact] + private void should_resolve_a_valid_double_flag_with_TARGETING_MATCH_reason() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetDoubleDetails("double_key", 1200.25, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal(100.25, res.Result.Value); + Assert.Equal(ErrorType.None, res.Result.ErrorType); + Assert.Equal(Reason.TargetingMatch, res.Result.Reason); + Assert.Equal("True", res.Result.Variant); + } + + [Fact] + private void should_use_double_default_value_if_the_flag_is_disabled() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetDoubleDetails("disabled_double", 1225.34, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal(1225.34, res.Result.Value); + Assert.Equal(Reason.Disabled, res.Result.Reason); + } + + [Fact] + private void should_resolve_a_valid_value_flag_with_TARGETING_MATCH_reason() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetObjectDetails("object_key", null, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + var want = JsonSerializer.Serialize(new Value(new Structure(new Dictionary + { + { "test", new Value("test1") }, { "test2", new Value(false) }, { "test3", new Value(123.3) }, + { "test4", new Value(1) } + }))); + Assert.Equal(want, JsonSerializer.Serialize(res.Result.Value)); + Assert.Equal(ErrorType.None, res.Result.ErrorType); + Assert.Equal(Reason.TargetingMatch, res.Result.Reason); + Assert.Equal("True", res.Result.Variant); + } + + [Fact] + private void should_wrap_into_value_if_wrong_type() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetObjectDetails("string_key", null, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal(new Value("CC0000").AsString, res.Result.Value.AsString); + Assert.Equal(ErrorType.None, res.Result.ErrorType); + Assert.Equal(Reason.TargetingMatch, res.Result.Reason); + Assert.Equal("True", res.Result.Variant); + } + + [Fact] + private void should_use_object_default_value_if_the_flag_is_disabled() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetObjectDetails("disabled_object", new Value("default"), _defaultEvaluationCtx); + Assert.NotNull(res.Result); + Assert.Equal(new Value("default").AsString, res.Result.Value.AsString); + Assert.Equal(Reason.Disabled, res.Result.Reason); + } + + + [Fact] + private void should_throw_an_error_if_no_targeting_key() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetStringDetails("list_key", "empty", EvaluationContext.Empty); + Assert.NotNull(res.Result); + Assert.Equal("empty", res.Result.Value); + Assert.Equal(ErrorType.InvalidContext, res.Result.ErrorType); + Assert.Equal(Reason.Error, res.Result.Reason); + } + + [Fact] + private void should_resolve_a_valid_value_flag_with_a_list() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + Api.Instance.SetProvider(g); + var client = Api.Instance.GetClient("test-client"); + var res = client.GetObjectDetails("list_key", null, _defaultEvaluationCtx); + Assert.NotNull(res.Result); + var want = JsonSerializer.Serialize(new Value(new List + { new("test"), new("test1"), new("test2"), new("false"), new("test3") })); + Assert.Equal(want, JsonSerializer.Serialize(res.Result.Value)); + Assert.Equal(ErrorType.None, res.Result.ErrorType); + Assert.Equal(Reason.TargetingMatch, res.Result.Reason); + Assert.Equal("True", res.Result.Variant); + } +} \ No newline at end of file diff --git a/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/HttpClientMock.cs b/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/HttpClientMock.cs new file mode 100644 index 00000000..6af6bbf2 --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/HttpClientMock.cs @@ -0,0 +1,34 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.Protected; + +namespace OpenFeature.Contrib.Providers.GOFeatureFlag.Test; + +public class HttpClientMock +{ + public static Mock GetResults(T response) + { + var mockResponse = new HttpResponseMessage + { + Content = new StringContent(JsonSerializer.Serialize(response)), + StatusCode = HttpStatusCode.OK + }; + + mockResponse.Content.Headers.ContentType = + new MediaTypeHeaderValue("application/json"); + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(mockResponse); + return mockHandler; + } +} \ No newline at end of file diff --git a/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test.csproj b/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test.csproj new file mode 100644 index 00000000..96b51275 --- /dev/null +++ b/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test.csproj @@ -0,0 +1,11 @@ + + + latest + + + + + + + +