From 4325672e0a12935835c89dc1520adae8b6b73671 Mon Sep 17 00:00:00 2001 From: adams85 <31276480+adams85@users.noreply.github.com> Date: Mon, 13 May 2024 13:29:22 +0200 Subject: [PATCH] Prepare v9.2.0 release (#91) * Implement missing GetKeyAndValue feature + port test suite from Java SDK * Adjust benchmarks project to changes * Bump version --- appveyor.yml | 2 +- benchmarks/NewVersionLib/BenchmarkHelper.cs | 4 +- .../EvaluationTestsBase.cs | 2 +- .../Helpers/ConfigLocation.LocalFile.cs | 4 + .../SynchronizationContextDeadlockTests.cs | 2 + .../{UtilsTest.cs => UtilsTests.cs} | 2 +- .../VariationIdTests.cs | 300 ++++++++++++++++++ src/ConfigCatClient/ConfigCatClient.cs | 69 +++- .../ConfigCatClientSnapshot.cs | 35 ++ ...uatorExtensions.cs => EvaluationHelper.cs} | 91 +++++- src/ConfigCatClient/IConfigCatClient.cs | 40 +++ .../IConfigCatClientSnapshot.cs | 17 +- src/ConfigCatClient/Logging/LogMessages.cs | 5 + 13 files changed, 560 insertions(+), 13 deletions(-) rename src/ConfigCat.Client.Tests/{UtilsTest.cs => UtilsTests.cs} (99%) create mode 100644 src/ConfigCat.Client.Tests/VariationIdTests.cs rename src/ConfigCatClient/Evaluation/{RolloutEvaluatorExtensions.cs => EvaluationHelper.cs} (58%) diff --git a/appveyor.yml b/appveyor.yml index ee3cde05..8666becb 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,5 +1,5 @@ environment: - build_version: 9.1.0 + build_version: 9.2.0 version: $(build_version)-{build} image: Visual Studio 2022 configuration: Release diff --git a/benchmarks/NewVersionLib/BenchmarkHelper.cs b/benchmarks/NewVersionLib/BenchmarkHelper.cs index be1d3127..e7b60ff0 100644 --- a/benchmarks/NewVersionLib/BenchmarkHelper.cs +++ b/benchmarks/NewVersionLib/BenchmarkHelper.cs @@ -40,7 +40,7 @@ public class BasicMatrixTestsDescriptor : IMatrixTestDescriptor UserCondition = new UserCondition() { ComparisonAttribute = nameof(User.Identifier), - Comparator = UserComparator.SensitiveIsOneOf, + Comparator = UserComparator.SensitiveTextIsOneOf, StringListValue = new[] { "61418c941ecda8031d08ab86ec821e676fde7b6a59cd16b1e7191503c2f8297d", @@ -60,7 +60,7 @@ public class BasicMatrixTestsDescriptor : IMatrixTestDescriptor UserCondition = new UserCondition() { ComparisonAttribute = nameof(User.Email), - Comparator = UserComparator.ContainsAnyOf, + Comparator = UserComparator.TextContainsAnyOf, StringListValue = new[] { "@example.com" } } }, diff --git a/src/ConfigCat.Client.Tests/EvaluationTestsBase.cs b/src/ConfigCat.Client.Tests/EvaluationTestsBase.cs index da9db788..f62736c8 100644 --- a/src/ConfigCat.Client.Tests/EvaluationTestsBase.cs +++ b/src/ConfigCat.Client.Tests/EvaluationTestsBase.cs @@ -60,7 +60,7 @@ public void GetValue_WithUser_ShouldReturnEvaluatedValue() private delegate EvaluationDetails EvaluateDelegate(IRolloutEvaluator evaluator, Dictionary settings, string key, object defaultValue, User user, ProjectConfig remoteConfig, LoggerWrapper logger); - private static readonly MethodInfo EvaluateMethodDefinition = new EvaluateDelegate(RolloutEvaluatorExtensions.Evaluate).Method.GetGenericMethodDefinition(); + private static readonly MethodInfo EvaluateMethodDefinition = new EvaluateDelegate(EvaluationHelper.Evaluate).Method.GetGenericMethodDefinition(); [DataRow("stringDefaultCat", "", "Cat", typeof(string))] [DataRow("stringDefaultCat", "", "Cat", typeof(object))] diff --git a/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs index d4fd8c8f..7435662d 100644 --- a/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs +++ b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.LocalFile.cs @@ -22,7 +22,11 @@ internal override Config FetchConfig() using Stream stream = File.OpenRead(FilePath); using StreamReader reader = new(stream); var configJson = reader.ReadToEnd(); +#if BENCHMARK_OLD + return configJson.Deserialize() ?? throw new InvalidOperationException("Invalid config JSON content: " + configJson); +#else return Config.Deserialize(configJson.AsMemory()); +#endif } } } diff --git a/src/ConfigCat.Client.Tests/SynchronizationContextDeadlockTests.cs b/src/ConfigCat.Client.Tests/SynchronizationContextDeadlockTests.cs index 56d53319..18f3a45d 100644 --- a/src/ConfigCat.Client.Tests/SynchronizationContextDeadlockTests.cs +++ b/src/ConfigCat.Client.Tests/SynchronizationContextDeadlockTests.cs @@ -87,6 +87,8 @@ public void LazyLoadDeadLockCheck() ["GetValueAsync"] = new object?[] { "x", null, null, CancellationToken.None }, ["GetValueDetails"] = new object?[] { "x", null, null }, ["GetValueDetailsAsync"] = new object?[] { "x", null, null, CancellationToken.None }, + ["GetKeyAndValue"] = new object?[] { "x" }, + ["GetKeyAndValueAsync"] = new object?[] { "x", CancellationToken.None }, ["SetDefaultUser"] = new object?[] { new User("id") }, }; diff --git a/src/ConfigCat.Client.Tests/UtilsTest.cs b/src/ConfigCat.Client.Tests/UtilsTests.cs similarity index 99% rename from src/ConfigCat.Client.Tests/UtilsTest.cs rename to src/ConfigCat.Client.Tests/UtilsTests.cs index 05caa6c3..47d3f07d 100644 --- a/src/ConfigCat.Client.Tests/UtilsTest.cs +++ b/src/ConfigCat.Client.Tests/UtilsTests.cs @@ -8,7 +8,7 @@ namespace ConfigCat.Client.Tests; [TestClass] -public class UtilsTest +public class UtilsTests { [DataRow(new byte[] { }, "")] [DataRow(new byte[] { 0 }, "00")] diff --git a/src/ConfigCat.Client.Tests/VariationIdTests.cs b/src/ConfigCat.Client.Tests/VariationIdTests.cs new file mode 100644 index 00000000..e7512499 --- /dev/null +++ b/src/ConfigCat.Client.Tests/VariationIdTests.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using ConfigCat.Client.ConfigService; +using ConfigCat.Client.Evaluation; +using ConfigCat.Client.Tests.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace ConfigCat.Client.Tests; + +[TestClass] +public class VariationIdTests +{ + private const string TestJson = "{ \"p\":{ \"u\": \"https://cdn-global.configcat.com\", \"r\": 0, \"s\": \"test-salt\"}, \"f\":{ \"key1\":{ \"t\":0, \"r\":[ { \"c\":[ { \"u\":{ \"a\": \"Email\", \"c\": 2 , \"l \":[ \"@configcat.com\" ] } } ], \"s\":{ \"v\": { \"b\":true }, \"i\": \"rolloutId1\" } }, { \"c\": [ { \"u\" :{ \"a\": \"Email\", \"c\": 2, \"l\" : [ \"@test.com\" ] } } ], \"s\" : { \"v\" : { \"b\": false }, \"i\": \"rolloutId2\" } } ], \"p\":[ { \"p\":50, \"v\" : { \"b\": true }, \"i\" : \"percentageId1\" }, { \"p\" : 50, \"v\" : { \"b\": false }, \"i\": \"percentageId2\" } ], \"v\":{ \"b\":true }, \"i\": \"fakeId1\" }, \"key2\": { \"t\":0, \"v\": { \"b\": false }, \"i\": \"fakeId2\" }, \"key3\": { \"t\": 0, \"r\":[ { \"c\": [ { \"u\":{ \"a\": \"Email\", \"c\":2, \"l\":[ \"@configcat.com\" ] } } ], \"p\": [{ \"p\":50, \"v\":{ \"b\": true }, \"i\" : \"targetPercentageId1\" }, { \"p\": 50, \"v\": { \"b\":false }, \"i\" : \"targetPercentageId2\" } ] } ], \"v\":{ \"b\": false }, \"i\": \"fakeId3\" } } }"; + private const string TestJsonIncorrect = "{ \"p\":{ \"u\": \"https://cdn-global.configcat.com\", \"r\": 0, \"s\": \"test-salt\" }, \"f\" :{ \"incorrect\" : { \"t\": 0, \"r\": [ {\"c\": [ {\"u\": {\"a\": \"Email\", \"c\": 2, \"l\": [\"@configcat.com\"] } } ] } ],\"v\": {\"b\": false}, \"i\": \"incorrectId\" } } }"; + + [DataTestMethod] + [DataRow(null)] + [DataRow(false)] + [DataRow(true)] + public async Task GetVariationId_Works(bool? isAsync) + { + using var client = CreateClient(isAsync, TestJson); + + const string key = "key1"; + var valueDetails = + isAsync is null ? client.Snapshot().GetValueDetails(key, null) : + !isAsync.Value ? client.GetValueDetails(key, null) : + await client.GetValueDetailsAsync(key, null); + Assert.AreEqual("fakeId1", valueDetails.VariationId); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(false)] + [DataRow(true)] + public async Task GetVariationId_NotFound(bool? isAsync) + { + using var client = CreateClient(isAsync, TestJson); + + const string key = "nonexisting"; + var valueDetails = + isAsync is null ? client.Snapshot().GetValueDetails(key, null) : + !isAsync.Value ? client.GetValueDetails(key, null) : + await client.GetValueDetailsAsync(key, null); + Assert.IsNull(valueDetails.VariationId); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(false)] + [DataRow(true)] + public async Task GetAllVariationIds_Works(bool? isAsync) + { + using var client = CreateClient(isAsync, TestJson); + + ConfigCatClientSnapshot snapshot; + EvaluationDetails[] allValueDetails = + isAsync is null ? (snapshot = client.Snapshot()).GetAllKeys().Select(keys => snapshot.GetValueDetails(keys, null)).ToArray() : + !isAsync.Value ? client.GetAllValueDetails().ToArray() : + (await client.GetAllValueDetailsAsync()).ToArray(); + + Assert.AreEqual(3, allValueDetails.Length); + + Array.Sort(allValueDetails, (x, y) => StringComparer.Ordinal.Compare(x.Key, y.Key)); + Assert.AreEqual("fakeId1", allValueDetails[0].VariationId); + Assert.AreEqual("fakeId2", allValueDetails[1].VariationId); + Assert.AreEqual("fakeId3", allValueDetails[2].VariationId); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(false)] + [DataRow(true)] + public async Task GetAllVariationIds_Works_Empty(bool? isAsync) + { + using var client = CreateClient(isAsync, "{}"); + + ConfigCatClientSnapshot snapshot; + EvaluationDetails[] allValueDetails = + isAsync is null ? (snapshot = client.Snapshot()).GetAllKeys().Select(keys => snapshot.GetValueDetails(keys, null)).ToArray() : + !isAsync.Value ? client.GetAllValueDetails().ToArray() : + (await client.GetAllValueDetailsAsync()).ToArray(); + + Assert.AreEqual(0, allValueDetails.Length); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(false)] + [DataRow(true)] + public async Task GetKeyAndValue_Works(bool? isAsync) + { + using var client = CreateClient(isAsync, TestJson); + + async Task?> GetKeyAndValue(string variationId) + { + return + isAsync is null ? client.Snapshot().GetKeyAndValue(variationId) : + !isAsync.Value ? client.GetKeyAndValue(variationId) : + await client.GetKeyAndValueAsync(variationId); + } + + var result = await GetKeyAndValue("fakeId2"); + Assert.IsNotNull(result); + Assert.AreEqual("key2", result.Value.Key); + Assert.IsFalse(result.Value.Value); + + result = await GetKeyAndValue("percentageId2"); + Assert.IsNotNull(result); + Assert.AreEqual("key1", result.Value.Key); + Assert.IsFalse(result.Value.Value); + + result = await GetKeyAndValue("rolloutId1"); + Assert.IsNotNull(result); + Assert.AreEqual("key1", result.Value.Key); + Assert.IsTrue(result.Value.Value); + + result = await GetKeyAndValue("targetPercentageId2"); + Assert.IsNotNull(result); + Assert.AreEqual("key3", result.Value.Key); + Assert.IsFalse(result.Value.Value); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(false)] + [DataRow(true)] + public async Task GetKeyAndValue_Works_Nullable(bool? isAsync) + { + using var client = CreateClient(isAsync, TestJson); + + async Task?> GetKeyAndValue(string variationId) + { + return + isAsync is null ? client.Snapshot().GetKeyAndValue(variationId) : + !isAsync.Value ? client.GetKeyAndValue(variationId) : + await client.GetKeyAndValueAsync(variationId); + } + + var result = await GetKeyAndValue("fakeId2"); + Assert.IsNotNull(result); + Assert.AreEqual("key2", result.Value.Key); + Assert.IsFalse(result.Value.Value); + + result = await GetKeyAndValue("percentageId2"); + Assert.IsNotNull(result); + Assert.AreEqual("key1", result.Value.Key); + Assert.IsFalse(result.Value.Value); + + result = await GetKeyAndValue("rolloutId1"); + Assert.IsNotNull(result); + Assert.AreEqual("key1", result.Value.Key); + Assert.IsTrue(result.Value.Value); + + result = await GetKeyAndValue("targetPercentageId2"); + Assert.IsNotNull(result); + Assert.AreEqual("key3", result.Value.Key); + Assert.IsFalse(result.Value.Value); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(false)] + [DataRow(true)] + public async Task GetKeyAndValue_Works_Object(bool? isAsync) + { + using var client = CreateClient(isAsync, TestJson); + + async Task?> GetKeyAndValue(string variationId) + { + return + isAsync is null ? client.Snapshot().GetKeyAndValue(variationId) : + !isAsync.Value ? client.GetKeyAndValue(variationId) : + await client.GetKeyAndValueAsync(variationId); + } + + var result = await GetKeyAndValue("fakeId2"); + Assert.IsNotNull(result); + Assert.AreEqual("key2", result.Value.Key); + Assert.AreEqual(false, result.Value.Value); + + result = await GetKeyAndValue("percentageId2"); + Assert.IsNotNull(result); + Assert.AreEqual("key1", result.Value.Key); + Assert.AreEqual(false, result.Value.Value); + + result = await GetKeyAndValue("rolloutId1"); + Assert.IsNotNull(result); + Assert.AreEqual("key1", result.Value.Key); + Assert.AreEqual(true, result.Value.Value); + + result = await GetKeyAndValue("targetPercentageId2"); + Assert.IsNotNull(result); + Assert.AreEqual("key3", result.Value.Key); + Assert.AreEqual(false, result.Value.Value); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(false)] + [DataRow(true)] + public async Task GetKeyAndValue_NotFound(bool? isAsync) + { + var logEvents = new List(); + var logger = LoggingHelper.CreateCapturingLogger(logEvents); + + using var client = CreateClient(isAsync, TestJson, logger); + + const string variationId = "nonexisting"; + var result = + isAsync is null ? client.Snapshot().GetKeyAndValue(variationId) : + !isAsync.Value ? client.GetKeyAndValue(variationId) : + await client.GetKeyAndValueAsync(variationId); + + Assert.IsNull(result); + + Assert.AreEqual(1, logEvents.Count); + Assert.AreEqual(2011, logEvents[0].EventId); + Assert.IsNull(logEvents[0].Exception); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(false)] + [DataRow(true)] + public async Task GetKeyAndValue_TypeMismatch(bool? isAsync) + { + var logEvents = new List(); + var logger = LoggingHelper.CreateCapturingLogger(logEvents); + + using var client = CreateClient(isAsync, TestJson, logger); + + const string variationId = "fakeId2"; + var result = + isAsync is null ? client.Snapshot().GetKeyAndValue(variationId) : + !isAsync.Value ? client.GetKeyAndValue(variationId) : + await client.GetKeyAndValueAsync(variationId); + + Assert.IsNull(result); + + Assert.AreEqual(1, logEvents.Count); + Assert.AreEqual(1002, logEvents[0].EventId); + Assert.IsNotNull(logEvents[0].Exception); + StringAssert.Contains(logEvents[0].Exception!.Message, "is not of the expected type"); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(false)] + [DataRow(true)] + public async Task GetKeyAndValue_IncorrectTargetingRule(bool? isAsync) + { + var logEvents = new List(); + var logger = LoggingHelper.CreateCapturingLogger(logEvents); + + using var client = CreateClient(isAsync, TestJsonIncorrect, logger); + + const string variationId = "targetPercentageId2"; + var result = + isAsync is null ? client.Snapshot().GetKeyAndValue(variationId) : + !isAsync.Value ? client.GetKeyAndValue(variationId) : + await client.GetKeyAndValueAsync(variationId); + + Assert.IsNull(result); + + Assert.AreEqual(1, logEvents.Count); + Assert.AreEqual(1002, logEvents[0].EventId); + Assert.IsNotNull(logEvents[0].Exception); + StringAssert.Contains(logEvents[0].Exception!.Message, "THEN part is missing or invalid"); + } + + private static ConfigCatClient CreateClient(bool? isAsync, string configJson, IConfigCatLogger? logger = null) + { + logger ??= new Mock().Object; + + var evaluator = new RolloutEvaluator(logger.AsWrapper()); + var configServiceMock = new Mock(); + + var config = ConfigHelper.FromString(configJson, "\"123\"", DateTime.UtcNow); + if (isAsync is null) + { + configServiceMock.Setup(m => m.GetInMemoryConfig()).Returns(config); + } + else if (!isAsync.Value) + { + configServiceMock.Setup(m => m.GetConfig()).Returns(config); + } + else + { + configServiceMock.Setup(m => m.GetConfigAsync(It.IsAny())).ReturnsAsync(config); + } + + return new ConfigCatClient(configServiceMock.Object, logger, evaluator); + } +} diff --git a/src/ConfigCatClient/ConfigCatClient.cs b/src/ConfigCatClient/ConfigCatClient.cs index d2fa305a..5aea65ee 100644 --- a/src/ConfigCatClient/ConfigCatClient.cs +++ b/src/ConfigCatClient/ConfigCatClient.cs @@ -294,7 +294,7 @@ public T GetValue(string key, T defaultValue, User? user = null) { Logger.SettingEvaluationError(nameof(GetValue), key, nameof(defaultValue), defaultValue, ex); evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, - ex.Message, ex, RolloutEvaluatorExtensions.GetErrorCode(ex)); + ex.Message, ex, EvaluationHelper.GetErrorCode(ex)); value = defaultValue; } @@ -335,7 +335,7 @@ public async Task GetValueAsync(string key, T defaultValue, User? user = n { Logger.SettingEvaluationError(nameof(GetValueAsync), key, nameof(defaultValue), defaultValue, ex); evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, - ex.Message, ex, RolloutEvaluatorExtensions.GetErrorCode(ex)); + ex.Message, ex, EvaluationHelper.GetErrorCode(ex)); value = defaultValue; } @@ -371,7 +371,7 @@ public EvaluationDetails GetValueDetails(string key, T defaultValue, User? { Logger.SettingEvaluationError(nameof(GetValueDetails), key, nameof(defaultValue), defaultValue, ex); evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, - ex.Message, ex, RolloutEvaluatorExtensions.GetErrorCode(ex)); + ex.Message, ex, EvaluationHelper.GetErrorCode(ex)); } this.hooks.RaiseFlagEvaluated(evaluationDetails); @@ -409,7 +409,7 @@ public async Task> GetValueDetailsAsync(string key, T de { Logger.SettingEvaluationError(nameof(GetValueDetailsAsync), key, nameof(defaultValue), defaultValue, ex); evaluationDetails = EvaluationDetails.FromDefaultValue(key, defaultValue, fetchTime: settings.RemoteConfig?.TimeStamp, user, - ex.Message, ex, RolloutEvaluatorExtensions.GetErrorCode(ex)); + ex.Message, ex, EvaluationHelper.GetErrorCode(ex)); } this.hooks.RaiseFlagEvaluated(evaluationDetails); @@ -424,7 +424,7 @@ public IReadOnlyCollection GetAllKeys() try { var settings = GetSettings(); - if (!RolloutEvaluatorExtensions.CheckSettingsAvailable(settings.Value, Logger, defaultReturnValue)) + if (!EvaluationHelper.CheckSettingsAvailable(settings.Value, Logger, defaultReturnValue)) { return ArrayUtils.EmptyArray(); } @@ -444,7 +444,7 @@ public async Task> GetAllKeysAsync(CancellationToken try { var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); - if (!RolloutEvaluatorExtensions.CheckSettingsAvailable(settings.Value, Logger, defaultReturnValue)) + if (!EvaluationHelper.CheckSettingsAvailable(settings.Value, Logger, defaultReturnValue)) { return ArrayUtils.EmptyArray(); } @@ -599,6 +599,63 @@ public async Task> GetAllValueDetailsAsync(User return evaluationDetailsArray; } + /// + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] + public KeyValuePair? GetKeyAndValue(string variationId) + { + if (variationId is null) + { + throw new ArgumentNullException(nameof(variationId)); + } + + if (variationId.Length == 0) + { + throw new ArgumentException("Variation ID cannot be empty.", nameof(variationId)); + } + + typeof(T).EnsureSupportedSettingClrType(nameof(T)); + + const string defaultReturnValue = "null"; + try + { + var settings = GetSettings(); + return EvaluationHelper.GetKeyAndValue(settings.Value, variationId, Logger, defaultReturnValue); + } + catch (Exception ex) + { + Logger.SettingEvaluationError(nameof(GetKeyAndValue), defaultReturnValue, ex); + return null; + } + } + + /// + public async Task?> GetKeyAndValueAsync(string variationId, CancellationToken cancellationToken = default) + { + if (variationId is null) + { + throw new ArgumentNullException(nameof(variationId)); + } + + if (variationId.Length == 0) + { + throw new ArgumentException("Variation ID cannot be empty.", nameof(variationId)); + } + + typeof(T).EnsureSupportedSettingClrType(nameof(T)); + + const string defaultReturnValue = "null"; + try + { + var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); + return EvaluationHelper.GetKeyAndValue(settings.Value, variationId, Logger, defaultReturnValue); + } + catch (Exception ex) + { + Logger.SettingEvaluationError(nameof(GetKeyAndValueAsync), defaultReturnValue, ex); + return null; + } + } + /// [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] public RefreshResult ForceRefresh() diff --git a/src/ConfigCatClient/ConfigCatClientSnapshot.cs b/src/ConfigCatClient/ConfigCatClientSnapshot.cs index f546cb31..fd365393 100644 --- a/src/ConfigCatClient/ConfigCatClientSnapshot.cs +++ b/src/ConfigCatClient/ConfigCatClientSnapshot.cs @@ -142,4 +142,39 @@ public EvaluationDetails GetValueDetails(string key, T defaultValue, User? evaluationServices.Hooks.RaiseFlagEvaluated(evaluationDetails); return evaluationDetails; } + + /// > + public KeyValuePair? GetKeyAndValue(string variationId) + { + if (this.evaluationServicesOrFakeImpl is not EvaluationServices evaluationServices) + { + return this.evaluationServicesOrFakeImpl is not null + ? ((IConfigCatClientSnapshot)this.evaluationServicesOrFakeImpl).GetKeyAndValue(variationId) + : null; + } + + if (variationId is null) + { + throw new ArgumentNullException(nameof(variationId)); + } + + if (variationId.Length == 0) + { + throw new ArgumentException("Variation ID cannot be empty.", nameof(variationId)); + } + + typeof(T).EnsureSupportedSettingClrType(nameof(T)); + + const string defaultReturnValue = "null"; + try + { + var settings = this.settings; + return EvaluationHelper.GetKeyAndValue(settings.Value, variationId, evaluationServices.Logger, defaultReturnValue); + } + catch (Exception ex) + { + evaluationServices.Logger.SettingEvaluationError($"{nameof(ConfigCatClientSnapshot)}.{nameof(GetKeyAndValue)}", defaultReturnValue, ex); + return null; + } + } } diff --git a/src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs b/src/ConfigCatClient/Evaluation/EvaluationHelper.cs similarity index 58% rename from src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs rename to src/ConfigCatClient/Evaluation/EvaluationHelper.cs index 12428daa..1f540fac 100644 --- a/src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs +++ b/src/ConfigCatClient/Evaluation/EvaluationHelper.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using ConfigCat.Client.Utils; namespace ConfigCat.Client.Evaluation; -internal static class RolloutEvaluatorExtensions +internal static class EvaluationHelper { public static EvaluationDetails Evaluate(this IRolloutEvaluator evaluator, Dictionary? settings, string key, T defaultValue, User? user, ProjectConfig? remoteConfig, LoggerWrapper logger) @@ -78,6 +79,94 @@ public static EvaluationDetails[] EvaluateAll(this IRolloutEvaluator evaluator, return evaluationDetailsArray; } + internal static KeyValuePair? GetKeyAndValue(Dictionary? settings, string variationId, LoggerWrapper logger, string defaultReturnValue) + { + if (!CheckSettingsAvailable(settings, logger, defaultReturnValue)) + { + return null; + } + + if (FindKeyAndValue(settings, variationId, out var settingType) is { } kvp) + { + T value; + + if (typeof(T) != typeof(object)) + { + var expectedSettingType = typeof(T).ToSettingType(); + + // NOTE: We've already checked earlier in the call chain that T is an allowed type (see also TypeExtensions.EnsureSupportedSettingClrType). + Debug.Assert(expectedSettingType != Setting.UnknownType, "Type is not supported."); + + value = kvp.Value.GetValue(expectedSettingType)!; + } + else + { + value = (T)(settingType != Setting.UnknownType + ? kvp.Value.GetValue(settingType)! + : kvp.Value.GetValue()!); + } + + return new KeyValuePair(kvp.Key, value); + } + + logger.SettingForVariationIdIsNotPresent(variationId); + return null; + } + + private static KeyValuePair? FindKeyAndValue(Dictionary settings, string variationId, out SettingType settingType) + { + foreach (var kvp in settings) + { + var key = kvp.Key; + var setting = kvp.Value; + + if (setting.VariationId == variationId) + { + settingType = setting.SettingType; + return new KeyValuePair(key, setting.Value); + } + + foreach (var targetingRule in setting.TargetingRules) + { + if (targetingRule.SimpleValue is { } simpleValue) + { + if (simpleValue.VariationId == variationId) + { + settingType = setting.SettingType; + return new KeyValuePair(key, simpleValue.Value); + } + } + else if (targetingRule.PercentageOptions is { Length: > 0 } percentageOptions) + { + foreach (var percentageOption in percentageOptions) + { + if (percentageOption.VariationId == variationId) + { + settingType = setting.SettingType; + return new KeyValuePair(key, percentageOption.Value); + } + } + } + else + { + throw new InvalidConfigModelException("Targeting rule THEN part is missing or invalid."); + } + } + + foreach (var percentageOption in setting.PercentageOptions) + { + if (percentageOption.VariationId == variationId) + { + settingType = setting.SettingType; + return new KeyValuePair(key, percentageOption.Value); + } + } + } + + settingType = Setting.UnknownType; + return null; + } + internal static bool CheckSettingsAvailable([NotNullWhen(true)] Dictionary? settings, LoggerWrapper logger, string defaultReturnValue) { if (settings is null) diff --git a/src/ConfigCatClient/IConfigCatClient.cs b/src/ConfigCatClient/IConfigCatClient.cs index 410125bc..b2d0969c 100644 --- a/src/ConfigCatClient/IConfigCatClient.cs +++ b/src/ConfigCatClient/IConfigCatClient.cs @@ -187,6 +187,46 @@ public interface IConfigCatClient : IProvidesHooks, IDisposable /// A task that represents the asynchronous operation. The task result contains the list of values along with evaluation details. Task> GetAllValueDetailsAsync(User? user = null, CancellationToken cancellationToken = default); + /// + /// Returns the key of a feature flag or setting and its value identified by the given Variation ID (analytics) synchronously. + /// + /// + /// + /// Please be aware that calling this method on a thread pool thread or the main UI thread is safe only when the client is set up to use Auto or Manual Polling and in-memory caching. + /// Otherwise execution may involve I/O-bound (e.g. network) operations, because of which the executing thread may be blocked for a longer period of time. This can result in an unresponsive application. + /// In the case of problematic setups, it is recommended to use either the async version of the method or snaphots (see ). + /// + /// + /// + /// The type of the value. Only the following types are allowed: + /// , , , , and (both nullable and non-nullable).
+ /// The type must correspond to the setting type, otherwise will be returned. + ///
+ /// The Variation ID. + /// The key of the feature flag or setting and its value. + /// is . + /// is an empty string. + /// is not an allowed type. + [Obsolete("This method may lead to an unresponsive application (see remarks), thus it will be removed from the public API in a future major version. Please use either the async version of the method or snaphots.")] + KeyValuePair? GetKeyAndValue(string variationId); + + /// + /// Returns the key of a feature flag or setting and its value identified by the given Variation ID (analytics) asynchronously. + /// + /// + /// The type of the value. Only the following types are allowed: + /// , , , , and (both nullable and non-nullable).
+ /// The type must correspond to the setting type, otherwise will be returned. + ///
+ /// The Variation ID. + /// A to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the key of the feature flag or setting and its value. + /// is . + /// is an empty string. + /// is not an allowed type. + /// is canceled during the execution of the task. + Task?> GetKeyAndValueAsync(string variationId, CancellationToken cancellationToken = default); + /// /// Refreshes the locally cached config by fetching the latest version from the remote server synchronously. /// diff --git a/src/ConfigCatClient/IConfigCatClientSnapshot.cs b/src/ConfigCatClient/IConfigCatClientSnapshot.cs index 1c02557a..f22da0c6 100644 --- a/src/ConfigCatClient/IConfigCatClientSnapshot.cs +++ b/src/ConfigCatClient/IConfigCatClientSnapshot.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace ConfigCat.Client; @@ -70,4 +70,19 @@ public interface IConfigCatClientSnapshot /// is an empty string. /// is not an allowed type. EvaluationDetails GetValueDetails(string key, T defaultValue, User? user = null); + + /// + /// Returns the key of a feature flag or setting and its value identified by the given Variation ID (analytics) synchronously, based on the snapshot. + /// + /// + /// The type of the value. Only the following types are allowed: + /// , , , , and (both nullable and non-nullable).
+ /// The type must correspond to the setting type, otherwise will be returned. + ///
+ /// The Variation ID. + /// The key of the feature flag or setting and its value. + /// is . + /// is an empty string. + /// is not an allowed type. + KeyValuePair? GetKeyAndValue(string variationId); } diff --git a/src/ConfigCatClient/Logging/LogMessages.cs b/src/ConfigCatClient/Logging/LogMessages.cs index 2cb94686..56038150 100644 --- a/src/ConfigCatClient/Logging/LogMessages.cs +++ b/src/ConfigCatClient/Logging/LogMessages.cs @@ -91,6 +91,11 @@ public static FormattableLogMessage LocalFileDataSourceFailedToReadFile(this Log #region SDK-specific error messages (2000-2999) + public static FormattableLogMessage SettingForVariationIdIsNotPresent(this LoggerWrapper logger, string variationId) => logger.LogInterpolated( + LogLevel.Error, 2011, + $"Could not find the setting for the specified variation ID: '{variationId}'.", + "VARIATION_ID"); + public static FormattableLogMessage EstablishingSecureConnectionFailed(this LoggerWrapper logger, Exception ex) => logger.Log( LogLevel.Error, 2100, ex, "Secure connection could not be established. Please make sure that your application is enabled to use TLS 1.2+. For more information, see https://stackoverflow.com/a/58195987/8656352");