diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProvider.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProvider.cs index 4b22ec8a..3d8d57cb 100644 --- a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProvider.cs +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProvider.cs @@ -5,10 +5,12 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; using OpenFeature.Constant; using OpenFeature.Contrib.Providers.GOFeatureFlag.converters; using OpenFeature.Contrib.Providers.GOFeatureFlag.exception; @@ -16,7 +18,9 @@ using OpenFeature.Contrib.Providers.GOFeatureFlag.hooks; using OpenFeature.Contrib.Providers.GOFeatureFlag.models; using OpenFeature.Model; +using ZiggyCreatures.Caching.Fusion; +[assembly: InternalsVisibleTo("OpenFeature.Contrib.Providers.GOFeatureFlag.Test")] namespace OpenFeature.Contrib.Providers.GOFeatureFlag { /// @@ -28,6 +32,9 @@ public class GoFeatureFlagProvider : FeatureProvider private ExporterMetadata _exporterMetadata; private HttpClient _httpClient; + internal IFusionCache _cache = null; + internal MemoryCache _backingCache = null; + /// /// Constructor of the provider. /// Options used while creating the provider @@ -87,8 +94,27 @@ private void InitializeProvider(GoFeatureFlagProviderOptions options) if (options.ApiKey != null) _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey); + + + + _backingCache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = options.CacheMaxSize, + CompactionPercentage = 0.1, + }); + + _cache = new FusionCache(new FusionCacheOptions + { + DefaultEntryOptions = new FusionCacheEntryOptions + { + Size = 1, + Duration = options.CacheMaxTTL, + }, + + }, _backingCache); } + /// /// Return the metadata associated to this provider. /// @@ -114,9 +140,13 @@ public override async Task> ResolveBooleanValueAsync(str { try { - var resp = await CallApi(flagKey, defaultValue, context).ConfigureAwait(false); - return new ResolutionDetails(flagKey, bool.Parse(resp.Value.ToString()), ErrorType.None, - resp.Reason, resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata()); + var key = GenerateCacheKey(flagKey, context); + return await _cache.GetOrSetAsync>(key, async (_, _) => + { + var resp = await CallApi(flagKey, defaultValue, context).ConfigureAwait(false); + return new ResolutionDetails(flagKey, bool.Parse(resp.Value.ToString()), ErrorType.None, + resp.Reason, resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata()); + }).ConfigureAwait(false); } catch (FormatException e) { @@ -145,12 +175,16 @@ public override async Task> ResolveStringValueAsync(st EvaluationContext context = null, CancellationToken cancellationToken = default) { try - { - var resp = await CallApi(flagKey, defaultValue, context).ConfigureAwait(false); - 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.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata()); + { + var key = GenerateCacheKey(flagKey, context); + return await _cache.GetOrSetAsync>(key, async (_, _) => + { + var resp = await CallApi(flagKey, defaultValue, context).ConfigureAwait(false); + 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.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata()); + }).ConfigureAwait(false); } catch (FormatException e) { @@ -179,9 +213,13 @@ public override async Task> ResolveIntegerValueAsync(stri { try { - var resp = await CallApi(flagKey, defaultValue, context).ConfigureAwait(false); - return new ResolutionDetails(flagKey, int.Parse(resp.Value.ToString()), ErrorType.None, - resp.Reason, resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata()); + var key = GenerateCacheKey(flagKey, context); + return await _cache.GetOrSetAsync>(key, async (_, _) => + { + var resp = await CallApi(flagKey, defaultValue, context).ConfigureAwait(false); + return new ResolutionDetails(flagKey, int.Parse(resp.Value.ToString()), ErrorType.None, + resp.Reason, resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata()); + }).ConfigureAwait(false); } catch (FormatException e) { @@ -211,10 +249,14 @@ public override async Task> ResolveDoubleValueAsync(st { try { - var resp = await CallApi(flagKey, defaultValue, context).ConfigureAwait(false); - return new ResolutionDetails(flagKey, - double.Parse(resp.Value.ToString(), CultureInfo.InvariantCulture), ErrorType.None, - resp.Reason, resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata()); + var key = GenerateCacheKey(flagKey, context); + return await _cache.GetOrSetAsync>(key, async (_, _) => + { + var resp = await CallApi(flagKey, defaultValue, context).ConfigureAwait(false); + return new ResolutionDetails(flagKey, + double.Parse(resp.Value.ToString(), CultureInfo.InvariantCulture), ErrorType.None, + resp.Reason, resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata()); + }).ConfigureAwait(false); } catch (FormatException e) { @@ -244,15 +286,19 @@ public override async Task> ResolveStructureValueAsync( { try { - var resp = await CallApi(flagKey, defaultValue, context).ConfigureAwait(false); - if (resp.Value is JsonElement) - { - var value = ConvertValue((JsonElement)resp.Value); - return new ResolutionDetails(flagKey, value, ErrorType.None, resp.Reason, - resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata()); - } - - throw new TypeMismatchError($"flag value {flagKey} had unexpected type"); + var key = GenerateCacheKey(flagKey, context); + return await _cache.GetOrSetAsync>(key, async (_, _) => + { + var resp = await CallApi(flagKey, defaultValue, context).ConfigureAwait(false); + if (resp.Value is JsonElement) + { + var value = ConvertValue((JsonElement)resp.Value); + return new ResolutionDetails(flagKey, value, ErrorType.None, resp.Reason, + resp.Variant, resp.ErrorDetails, resp.Metadata.ToImmutableMetadata()); + } + + throw new TypeMismatchError($"flag value {flagKey} had unexpected type"); + }).ConfigureAwait(false); } catch (FormatException e) { @@ -311,6 +357,11 @@ private async Task CallApi(string flagKey, T defaultValue, return ofrepResp; } + private string GenerateCacheKey(string flagKey, EvaluationContext ctx) + { + return ctx != null ? flagKey + ":" + new OfrepRequest(ctx).AsJsonString().GetHashCode() : flagKey; + } + /// /// convertValue is converting the object return by the proxy response in the right type. /// diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProviderOptions.cs b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProviderOptions.cs index 3d3100cc..9057683c 100644 --- a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProviderOptions.cs +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/GoFeatureFlagProviderOptions.cs @@ -40,6 +40,19 @@ public class GoFeatureFlagProviderOptions /// (optional) ExporterMetadata are static information you can set that will be available in the /// evaluation data sent to the exporter. /// - public ExporterMetadata ExporterMetadata { get; set; } + public ExporterMetadata ExporterMetadata { get; set; } + + + /// + /// (optional) How long is an entry allowed to be cached in memory + /// Default: 60 seconds + /// + public TimeSpan CacheMaxTTL { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// (optional) How many entries may be cached + /// Default: 10000 + /// + public int CacheMaxSize { get; set; } = 10000; } } diff --git a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/OpenFeature.Contrib.Providers.GOFeatureFlag.csproj b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/OpenFeature.Contrib.Providers.GOFeatureFlag.csproj index d80eec46..74c9b564 100644 --- a/src/OpenFeature.Contrib.Providers.GOFeatureFlag/OpenFeature.Contrib.Providers.GOFeatureFlag.csproj +++ b/src/OpenFeature.Contrib.Providers.GOFeatureFlag/OpenFeature.Contrib.Providers.GOFeatureFlag.csproj @@ -1,4 +1,4 @@ - + OpenFeature.Contrib.GOFeatureFlag @@ -20,8 +20,12 @@ + + + + - 8.0 + 9.0 \ No newline at end of file diff --git a/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/GoFeatureFlagProviderTest.cs b/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/GoFeatureFlagProviderTest.cs index d52b90b7..411a4606 100644 --- a/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/GoFeatureFlagProviderTest.cs +++ b/test/OpenFeature.Contrib.Providers.GOFeatureFlag.Test/GoFeatureFlagProviderTest.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Threading.Tasks; using Newtonsoft.Json.Linq; +using NSubstitute; using OpenFeature.Constant; using OpenFeature.Contrib.Providers.GOFeatureFlag.exception; using OpenFeature.Contrib.Providers.GOFeatureFlag.models; @@ -17,14 +18,16 @@ namespace OpenFeature.Contrib.Providers.GOFeatureFlag.Test; public class GoFeatureFlagProviderTest { + private static readonly string mediaType = "application/json"; private static readonly string baseUrl = "http://gofeatureflag.org"; private static readonly string prefixEval = baseUrl + "/ofrep/v1/evaluate/flags/"; private readonly EvaluationContext _defaultEvaluationCtx = InitDefaultEvaluationCtx(); - private readonly HttpMessageHandler _mockHttp = InitMock(); - - private static HttpMessageHandler InitMock() - { - const string mediaType = "application/json"; + private readonly MockHttpMessageHandler _mockHttp = InitMock(); + + + private static MockHttpMessageHandler InitMock() + { + var mockHttp = new MockHttpMessageHandler(); mockHttp.When($"{prefixEval}fail_500").Respond(HttpStatusCode.InternalServerError); mockHttp.When($"{prefixEval}api_key_missing").Respond(HttpStatusCode.BadRequest); @@ -59,8 +62,15 @@ private static HttpMessageHandler InitMock() mockHttp.When($"{prefixEval}does_not_exists").Respond(mediaType, "{ \"value\":\"\", \"key\":\"does_not_exists\", \"errorCode\":\"FLAG_NOT_FOUND\", \"variant\":\"defaultSdk\", \"cacheable\":true, \"errorDetails\":\"flag does_not_exists was not found in your configuration\"}"); mockHttp.When($"{prefixEval}integer_with_metadata").Respond(mediaType, - "{ \"value\":100, \"key\":\"integer_key\", \"reason\":\"TARGETING_MATCH\", \"variant\":\"True\", \"cacheable\":true, \"metadata\":{\"key1\": \"key1\", \"key2\": 1, \"key3\": 1.345, \"key4\": true}}"); + "{ \"value\":100, \"key\":\"integer_key\", \"reason\":\"TARGETING_MATCH\", \"variant\":\"True\", \"cacheable\":true, \"metadata\":{\"key1\": \"key1\", \"key2\": 1, \"key3\": 1.345, \"key4\": true}}"); + for (int i = 0; i < 5; i++) + { + mockHttp.When($"{prefixEval}string_key{i}").Respond(mediaType, + $"{{ \"value\":\"C{i}\", \"key\":\"string_key{i}\", \"reason\":\"TARGETING_MATCH\", \"variant\":\"True\", \"cacheable\":true}}"); + } + return mockHttp; + } private static EvaluationContext InitDefaultEvaluationCtx() @@ -300,8 +310,89 @@ public async Task should_throw_an_error_if_we_expect_a_string_and_got_another_ty Assert.Equal("default", result.Value); Assert.Equal(ErrorType.TypeMismatch, result.ErrorType); Assert.Equal(Reason.Error, result.Reason); - } + } + + [Fact] + public async Task should_cache_2nd_call() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond) + }); + var req = _mockHttp.When($"{prefixEval}string_key_cache").Respond(mediaType, + "{ \"value\":\"CC00AA\", \"key\":\"string_key_cache\", \"reason\":\"TARGETING_MATCH\", \"variant\":\"True\", \"cacheable\":true}"); + Assert.Equal(0, _mockHttp.GetMatchCount(req)); + + await Api.Instance.SetProviderAsync(g); + var client = Api.Instance.GetClient("test-client"); + var result = await client.GetStringDetailsAsync("string_key_cache", "defaultValue", _defaultEvaluationCtx); + Assert.NotNull(result); + Assert.Equal("CC00AA", result.Value); + Assert.Equal(1, _mockHttp.GetMatchCount(req)); + + var result2 = await client.GetStringDetailsAsync("string_key_cache", "defaultValue", _defaultEvaluationCtx); + Assert.NotNull(result2); + Assert.Equal("CC00AA", result.Value); + Assert.Equal(1, _mockHttp.GetMatchCount(req)); // 2nd lookup should hit cache and not activate http + } + + + + [Fact] + public async Task should_limit_cached_items() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond), + CacheMaxSize = 2 + }); + await Api.Instance.SetProviderAsync(g); + var client = Api.Instance.GetClient("test-client"); + + for (var i = 0; i < 5; i++) + { + var res1 = await client.GetStringDetailsAsync($"string_key{i}", "defaultValue", _defaultEvaluationCtx); + Assert.NotNull(res1); + Assert.Equal($"C{i}", res1.Value); + } + + Assert.Equal(2, g._backingCache.Count); + } + + [Fact] + public async Task should_not_cache() + { + var g = new GoFeatureFlagProvider(new GoFeatureFlagProviderOptions + { + Endpoint = baseUrl, + HttpMessageHandler = _mockHttp, + Timeout = new TimeSpan(1000 * TimeSpan.TicksPerMillisecond), + CacheMaxTTL = TimeSpan.FromSeconds(0) + }); + var req = _mockHttp.When($"{prefixEval}string_key_cache").Respond(mediaType, + "{ \"value\":\"CC00AA\", \"key\":\"string_key_cache\", \"reason\":\"TARGETING_MATCH\", \"variant\":\"True\", \"cacheable\":true}"); + + + Assert.Equal(0, _mockHttp.GetMatchCount(req)); + await Api.Instance.SetProviderAsync(g); + var client = Api.Instance.GetClient("test-client"); + var result = await client.GetStringDetailsAsync("string_key_cache", "defaultValue", _defaultEvaluationCtx); + Assert.NotNull(result); + Assert.Equal("CC00AA", result.Value); + Assert.Equal(1, _mockHttp.GetMatchCount(req)); + + var result2 = await client.GetStringDetailsAsync("string_key_cache", "defaultValue", _defaultEvaluationCtx); + Assert.NotNull(result2); + Assert.Equal("CC00AA", result.Value); + Assert.Equal(2, _mockHttp.GetMatchCount(req)); // 2nd lookup should not be cached, but hit http bringing total matches up + } + + [Fact] public async Task should_resolve_a_valid_string_flag_with_TARGETING_MATCH_reason() { @@ -387,6 +478,7 @@ public async Task should_use_integer_default_value_if_the_flag_is_disabled() await Api.Instance.SetProviderAsync(g); var client = Api.Instance.GetClient("test-client"); var result = await client.GetIntegerDetailsAsync("disabled_integer", 1225, _defaultEvaluationCtx); + Assert.NotNull(result); Assert.Equal(1225, result.Value); Assert.Equal(Reason.Disabled, result.Reason);