From 1f721c40f231f71cce6ce9f693038ac7e7be9b65 Mon Sep 17 00:00:00 2001 From: adams85 <31276480+adams85@users.noreply.github.com> Date: Thu, 10 Nov 2022 09:01:08 +0100 Subject: [PATCH] Additional minor improvements (#51) * Improves error reporting when setting type and default value type mismatch * Improves ConfigDeserializer performance by using ETag for change detection instead of calculating a hash * Improves SHA1 hashing implementation * Eliminates ModuleInitalizer trick used in tests because MSTest provides that feature out of the box --- LICENSE | 3 - ...eInitializer.cs => AssemblyInitializer.cs} | 9 +- .../BasicConfigEvaluatorTests.cs | 72 ++++++++ .../ConfigCatClientTests.cs | 10 +- .../ConfigEvaluatorTestsBase.cs | 4 +- .../DeserializerTests.cs | 2 +- .../ModuleInitializerAttribute.cs | 37 ---- src/ConfigCatClient/ConfigCatClient.cs | 8 +- src/ConfigCatClient/ConfigCatClient.csproj | 1 - src/ConfigCatClient/ConfigDeserializer.cs | 26 +-- .../Evaluation/EvaluationDetails.cs | 54 +++++- .../Evaluation/RolloutEvaluatorExtensions.cs | 2 +- src/ConfigCatClient/Evaluation/Setting.cs | 5 +- .../Extensions/ObjectExtensions.cs | 158 ++++++++++++++---- .../Extensions/StringExtensions.cs | 12 +- .../Extensions/TypeExtensions.cs | 43 +++++ src/ConfigCatClient/HttpConfigFetcher.cs | 4 +- src/ConfigCatClient/IConfigDeserializer.cs | 3 +- 18 files changed, 334 insertions(+), 119 deletions(-) rename src/ConfigCat.Client.Tests/{ModuleInitializer.cs => AssemblyInitializer.cs} (67%) delete mode 100644 src/ConfigCat.Client.Tests/ModuleInitializerAttribute.cs create mode 100644 src/ConfigCatClient/Extensions/TypeExtensions.cs diff --git a/LICENSE b/LICENSE index b7aa816a..c048680b 100644 --- a/LICENSE +++ b/LICENSE @@ -20,9 +20,6 @@ The files containing such source code are the following: * src/ConfigCatClient/Versioning/IntExtensions.cs, which is originally available at https://github.com/maxhauser/semver/blob/v2.2.0/Semver/Utility/IntExtensions.cs and is licensed under MIT license (see https://github.com/maxhauser/semver/blob/v2.2.0/License.txt). -* src/ConfigCat.Client.Tests/ModuleInitializerAttribute.cs, which - is originally available at https://github.com/dotnet/runtime/blob/v6.0.9/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/ModuleInitializerAttribute.cs and - is licensed under MIT license (see https://github.com/dotnet/runtime/blob/main/LICENSE.TXT). See the file headers for details. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR diff --git a/src/ConfigCat.Client.Tests/ModuleInitializer.cs b/src/ConfigCat.Client.Tests/AssemblyInitializer.cs similarity index 67% rename from src/ConfigCat.Client.Tests/ModuleInitializer.cs rename to src/ConfigCat.Client.Tests/AssemblyInitializer.cs index 70ec9f98..144419d4 100644 --- a/src/ConfigCat.Client.Tests/ModuleInitializer.cs +++ b/src/ConfigCat.Client.Tests/AssemblyInitializer.cs @@ -1,12 +1,13 @@ using System.Net; -using System.Runtime.CompilerServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; namespace ConfigCat.Client.Tests { - internal class ModuleInitializer + [TestClass] + public class AssemblyInitializer { - [ModuleInitializer] - internal static void Setup() + [AssemblyInitialize] + public static void AssemblyInitialize(TestContext context) { #if NET45 // TLS 1.2 was not enabled before .NET 4.6 by default (see https://stackoverflow.com/a/58195987/8656352), diff --git a/src/ConfigCat.Client.Tests/BasicConfigEvaluatorTests.cs b/src/ConfigCat.Client.Tests/BasicConfigEvaluatorTests.cs index fee21063..abdb7f4f 100644 --- a/src/ConfigCat.Client.Tests/BasicConfigEvaluatorTests.cs +++ b/src/ConfigCat.Client.Tests/BasicConfigEvaluatorTests.cs @@ -1,6 +1,11 @@ using ConfigCat.Client.Evaluation; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System; using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; namespace ConfigCat.Client.Tests { @@ -48,5 +53,72 @@ public void GetValue_WithUser_ShouldReturnEvaluatedValue() Assert.AreEqual(3.1415, actual); } + + private delegate object EvaluateDelegate(IRolloutEvaluator evaluator, IDictionary settings, string key, object defaultValue, User user, + ProjectConfig remoteConfig, ILogger logger, out EvaluationDetails evaluationDetails); + + private static readonly MethodInfo evaluateMethodDefinition = new EvaluateDelegate(RolloutEvaluatorExtensions.Evaluate).Method.GetGenericMethodDefinition(); + + [DataRow("stringDefaultCat", "", "Cat", typeof(string))] + [DataRow("stringDefaultCat", "", "Cat", typeof(object))] + [DataRow("boolDefaultTrue", false, true, typeof(bool))] + [DataRow("boolDefaultTrue", false, true, typeof(bool?))] + [DataRow("boolDefaultTrue", false, true, typeof(object))] + [DataRow("integerDefaultOne", 0, 1, typeof(int))] + [DataRow("integerDefaultOne", 0, 1, typeof(int?))] + [DataRow("integerDefaultOne", 0L, 1L, typeof(long))] + [DataRow("integerDefaultOne", 0L, 1L, typeof(long?))] + [DataRow("integerDefaultOne", 0, 1, typeof(object))] + [DataRow("doubleDefaultPi", 0.0, 3.1415, typeof(double))] + [DataRow("doubleDefaultPi", 0.0, 3.1415, typeof(double?))] + [DataRow("doubleDefaultPi", 0.0, 3.1415, typeof(object))] + [DataTestMethod] + public void GetValue_WithCompatibleDefaultValue_ShouldSucceed(string key, object defaultValue, object expectedValue, Type settingClrType) + { + var args = new object[] + { + this.configEvaluator, + this.config, + key, + defaultValue, + null, + null, + this.logger, + null + }; + + var actualValue = evaluateMethodDefinition.MakeGenericMethod(settingClrType).Invoke(null, args); + var evaluationDetails = (EvaluationDetails)args.Last(); + + Assert.AreEqual(expectedValue, actualValue); + Assert.AreEqual(expectedValue, evaluationDetails.Value); + } + + [DataRow("stringDefaultCat", 0.0, typeof(double?))] + [DataRow("boolDefaultTrue", "false", typeof(string))] + [DataRow("integerDefaultOne", "0", typeof(string))] + [DataRow("doubleDefaultPi", 0, typeof(int))] + [DataTestMethod] + public void GetValue_WithIncompatibleDefaultValueType_ShouldThrowWithImprovedErrorMessage(string key, object defaultValue, Type settingClrType) + { + var args = new object[] + { + this.configEvaluator, + this.config, + key, + defaultValue, + null, + null, + this.logger, + null + }; + + var ex = Assert.ThrowsException(() => + { + try { evaluateMethodDefinition.MakeGenericMethod(settingClrType).Invoke(null, args); } + catch (TargetInvocationException ex) { throw ex.InnerException; } + }); + StringAssert.Contains(ex.Message, $"Setting's type was {this.config[key].SettingType} but the default value's type was {settingClrType}."); + } } } diff --git a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs index aba5044b..8907e0a5 100644 --- a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs @@ -706,7 +706,7 @@ public void GetAllKeys_DeserializerThrowException_ShouldReturnsWithEmptyArray() configServiceMock.Setup(m => m.GetConfigAsync()).ReturnsAsync(ProjectConfig.Empty); var o = new SettingsWithPreferences(); configDeserializerMock - .Setup(m => m.TryDeserialize(It.IsAny(), out o)) + .Setup(m => m.TryDeserialize(It.IsAny(), It.IsAny(), out o)) .Throws(); IConfigCatClient instance = new ConfigCatClient( @@ -734,7 +734,7 @@ public async Task GetAllKeysAsync_DeserializeFailed_ShouldReturnsWithEmptyArray( configServiceMock.Setup(m => m.GetConfigAsync()).ReturnsAsync(ProjectConfig.Empty); var o = new SettingsWithPreferences(); configDeserializerMock - .Setup(m => m.TryDeserialize(It.IsAny(), out o)) + .Setup(m => m.TryDeserialize(It.IsAny(), It.IsAny(), out o)) .Returns(false); IConfigCatClient instance = new ConfigCatClient( @@ -762,7 +762,7 @@ public void GetAllKeys_DeserializeFailed_ShouldReturnsWithEmptyArray() configServiceMock.Setup(m => m.GetConfig()).Returns(ProjectConfig.Empty); var o = new SettingsWithPreferences(); configDeserializerMock - .Setup(m => m.TryDeserialize(It.IsAny(), out o)) + .Setup(m => m.TryDeserialize(It.IsAny(), It.IsAny(), out o)) .Returns(false); IConfigCatClient instance = new ConfigCatClient( @@ -849,7 +849,7 @@ public void GetVariationId_DeserializeFailed_ShouldReturnsWithEmptyArray() configServiceMock.Setup(m => m.GetConfigAsync()).ReturnsAsync(ProjectConfig.Empty); var o = new SettingsWithPreferences(); configDeserializerMock - .Setup(m => m.TryDeserialize(It.IsAny(), out o)) + .Setup(m => m.TryDeserialize(It.IsAny(), It.IsAny(), out o)) .Returns(false); IConfigCatClient instance = new ConfigCatClient( @@ -877,7 +877,7 @@ public async Task GetVariationIdAsync_DeserializeFailed_ShouldReturnsWithEmptyAr configServiceMock.Setup(m => m.GetConfigAsync()).ReturnsAsync(ProjectConfig.Empty); var o = new SettingsWithPreferences(); configDeserializerMock - .Setup(m => m.TryDeserialize(It.IsAny(), out o)) + .Setup(m => m.TryDeserialize(It.IsAny(), It.IsAny(), out o)) .Returns(false); IConfigCatClient instance = new ConfigCatClient( diff --git a/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs b/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs index 3fa24129..67d5bdd5 100644 --- a/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs +++ b/src/ConfigCat.Client.Tests/ConfigEvaluatorTestsBase.cs @@ -60,14 +60,14 @@ protected virtual void AssertValue(string keyName, string expected, User user) protected string GetSampleJson() { - using Stream stream = File.OpenRead("data" + Path.DirectorySeparatorChar + this.SampleJsonFileName); + using Stream stream = File.OpenRead(Path.Combine("data", this.SampleJsonFileName)); using StreamReader reader = new(stream); return reader.ReadToEnd(); } public async Task MatrixTest(Action assertation) { - using Stream stream = File.OpenRead("data" + Path.DirectorySeparatorChar + this.MatrixResultFileName); + using Stream stream = File.OpenRead(Path.Combine("data", this.MatrixResultFileName)); using StreamReader reader = new(stream); var header = await reader.ReadLineAsync(); diff --git a/src/ConfigCat.Client.Tests/DeserializerTests.cs b/src/ConfigCat.Client.Tests/DeserializerTests.cs index 78851b83..2fadb413 100644 --- a/src/ConfigCat.Client.Tests/DeserializerTests.cs +++ b/src/ConfigCat.Client.Tests/DeserializerTests.cs @@ -18,7 +18,7 @@ public void Ensure_Global_Settings_Doesnt_Interfere() }; var deserializer = new ConfigDeserializer(); - deserializer.TryDeserialize("{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}", out var configs); + deserializer.TryDeserialize("{\"p\": {\"u\": \"http://example.com\", \"r\": 0}}", httpETag: null, out var configs); } } } diff --git a/src/ConfigCat.Client.Tests/ModuleInitializerAttribute.cs b/src/ConfigCat.Client.Tests/ModuleInitializerAttribute.cs deleted file mode 100644 index c210718c..00000000 --- a/src/ConfigCat.Client.Tests/ModuleInitializerAttribute.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET5_0_OR_GREATER - -namespace System.Runtime.CompilerServices -{ - /// - /// Used to indicate to the compiler that a method should be called - /// in its containing module's initializer. - /// - /// - /// When one or more valid methods - /// with this attribute are found in a compilation, the compiler will - /// emit a module initializer which calls each of the attributed methods. - /// - /// Certain requirements are imposed on any method targeted with this attribute: - /// - The method must be `static`. - /// - The method must be an ordinary member method, as opposed to a property accessor, constructor, local function, etc. - /// - The method must be parameterless. - /// - The method must return `void`. - /// - The method must not be generic or be contained in a generic type. - /// - The method's effective accessibility must be `internal` or `public`. - /// - /// The specification for module initializers in the .NET runtime can be found here: - /// https://github.com/dotnet/runtime/blob/main/docs/design/specs/Ecma-335-Augments.md#module-initializer - /// - [AttributeUsage(AttributeTargets.Method, Inherited = false)] - internal sealed class ModuleInitializerAttribute : Attribute - { - public ModuleInitializerAttribute() - { - } - } -} - -#endif \ No newline at end of file diff --git a/src/ConfigCatClient/ConfigCatClient.cs b/src/ConfigCatClient/ConfigCatClient.cs index c5b290fe..7f8030b5 100644 --- a/src/ConfigCatClient/ConfigCatClient.cs +++ b/src/ConfigCatClient/ConfigCatClient.cs @@ -257,6 +257,7 @@ public T GetValue(string key, T defaultValue, User user = null) user ??= this.defaultUser; try { + typeof(T).EnsureSupportedSettingClrType(); settings = this.GetSettings(); value = this.configEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, this.log, out evaluationDetails); } @@ -280,6 +281,7 @@ public async Task GetValueAsync(string key, T defaultValue, User user = nu user ??= this.defaultUser; try { + typeof(T).EnsureSupportedSettingClrType(); settings = await this.GetSettingsAsync().ConfigureAwait(false); value = this.configEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, this.log, out evaluationDetails); } @@ -302,6 +304,7 @@ public EvaluationDetails GetValueDetails(string key, T defaultValue, User user ??= this.defaultUser; try { + typeof(T).EnsureSupportedSettingClrType(); settings = this.GetSettings(); this.configEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, this.log, out evaluationDetails); } @@ -323,6 +326,7 @@ public async Task> GetValueDetailsAsync(string key, T de user ??= this.defaultUser; try { + typeof(T).EnsureSupportedSettingClrType(); settings = await this.GetSettingsAsync().ConfigureAwait(false); this.configEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, this.log, out evaluationDetails); } @@ -684,7 +688,7 @@ private SettingsWithRemoteConfig GetSettings() SettingsWithRemoteConfig GetRemoteConfig() { var config = this.configService.GetConfig(); - if (!this.configDeserializer.TryDeserialize(config.JsonString, out var deserialized)) + if (!this.configDeserializer.TryDeserialize(config.JsonString, config.HttpETag, out var deserialized)) return new SettingsWithRemoteConfig(new Dictionary(), config); return new SettingsWithRemoteConfig(deserialized.Settings, config); @@ -717,7 +721,7 @@ private async Task GetSettingsAsync() async Task GetRemoteConfigAsync() { var config = await this.configService.GetConfigAsync().ConfigureAwait(false); - if (!this.configDeserializer.TryDeserialize(config.JsonString, out var deserialized)) + if (!this.configDeserializer.TryDeserialize(config.JsonString, config.HttpETag, out var deserialized)) return new SettingsWithRemoteConfig(new Dictionary(), config); return new SettingsWithRemoteConfig(deserialized.Settings, config); diff --git a/src/ConfigCatClient/ConfigCatClient.csproj b/src/ConfigCatClient/ConfigCatClient.csproj index 6213832f..2e657dea 100644 --- a/src/ConfigCatClient/ConfigCatClient.csproj +++ b/src/ConfigCatClient/ConfigCatClient.csproj @@ -59,7 +59,6 @@ Works with .NET, .NET Core, .NET Standard all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/src/ConfigCatClient/ConfigDeserializer.cs b/src/ConfigCatClient/ConfigDeserializer.cs index b580c042..bb16003e 100644 --- a/src/ConfigCatClient/ConfigDeserializer.cs +++ b/src/ConfigCatClient/ConfigDeserializer.cs @@ -1,17 +1,15 @@ using ConfigCat.Client.Evaluation; using System; -using System.Data.HashFunction; -using System.Data.HashFunction.MurmurHash; namespace ConfigCat.Client { internal class ConfigDeserializer : IConfigDeserializer { - private readonly IHashFunction hasher = MurmurHash3Factory.Instance.Create(new MurmurHash3Config { HashSizeInBits = 128 }); - private SettingsWithPreferences lastSerializedSettings; - private byte[] hashToCompare; + private SettingsWithPreferences lastDeserializedSettings; + private string lastConfig; + private string lastHttpETag; - public bool TryDeserialize(string config, out SettingsWithPreferences settings) + public bool TryDeserialize(string config, string httpETag, out SettingsWithPreferences settings) { if (config == null) { @@ -19,18 +17,20 @@ public bool TryDeserialize(string config, out SettingsWithPreferences settings) return false; } - var hash = this.hasher.ComputeHash(config).Hash; - if (CompareByteArrays(this.hashToCompare, hash)) + var configContentHasChanged = this.lastHttpETag is not null && httpETag is not null + ? this.lastHttpETag != httpETag + : this.lastConfig != config; + + if (!configContentHasChanged) { - settings = this.lastSerializedSettings; + settings = this.lastDeserializedSettings; return true; } - this.lastSerializedSettings = settings = config.Deserialize(); - this.hashToCompare = hash; + this.lastDeserializedSettings = settings = config.Deserialize(); + this.lastConfig = config; + this.lastHttpETag = httpETag; return true; } - - private static bool CompareByteArrays(ReadOnlySpan b1, ReadOnlySpan b2) => b1.SequenceEqual(b2); } } diff --git a/src/ConfigCatClient/Evaluation/EvaluationDetails.cs b/src/ConfigCatClient/Evaluation/EvaluationDetails.cs index ef502c62..a436c36a 100644 --- a/src/ConfigCatClient/Evaluation/EvaluationDetails.cs +++ b/src/ConfigCatClient/Evaluation/EvaluationDetails.cs @@ -16,17 +16,53 @@ namespace ConfigCat.Client /// public abstract record class EvaluationDetails { - internal static EvaluationDetails Create(JsonValue value) + private static EvaluationDetails Create(JsonValue value) { - return new EvaluationDetails + return new EvaluationDetails { Value = value.ConvertTo() }; + } + + internal static EvaluationDetails Create(SettingType settingType, JsonValue value) + { + var objectValueExpected = typeof(TValue) == typeof(object); + var expectedSettingType = typeof(TValue).ToSettingType(); + expectedSettingType.EnsureSupportedSettingType(isAnyAllowed: objectValueExpected); + + // SettingType was not specified in the config.json? + if (settingType == SettingType.Unknown) { + // Let's try to infer it from the JSON value. + settingType = value.DetermineSettingType(); -#if USE_NEWTONSOFT_JSON - Value = Newtonsoft.Json.Linq.Extensions.Value(value) -#else - Value = System.Text.Json.JsonSerializer.Deserialize(value) -#endif - }; + if (settingType == SettingType.Unknown) + { + throw new ArgumentException($"The type of setting value '{value}' is not supported.", nameof(value)); + } + } + + if (!objectValueExpected) + { + if (settingType != expectedSettingType) + { + throw new InvalidOperationException($"The type of a setting must match the type of the setting's default value.{Environment.NewLine}Setting's type was {settingType} but the default value's type was {typeof(TValue)}.{Environment.NewLine}Please use a default value which corresponds to the setting type {settingType}."); + } + + return Create(value); + } + else + { + EvaluationDetails evaluationDetails = new EvaluationDetails + { + Value = settingType switch + { + SettingType.Boolean => value.ConvertTo(), + SettingType.String => value.ConvertTo(), + SettingType.Int => value.ConvertTo(), + SettingType.Double => value.ConvertTo(), + _ => throw new ArgumentOutOfRangeException(nameof(settingType), settingType, null) + } + }; + return (EvaluationDetails)evaluationDetails; + } } internal static EvaluationDetails Create(SettingType settingType, JsonValue value) @@ -35,7 +71,7 @@ internal static EvaluationDetails Create(SettingType settingType, JsonValue valu { SettingType.Boolean => Create(value), SettingType.String => Create(value), - SettingType.Int => Create(value), + SettingType.Int => Create(value), SettingType.Double => Create(value), _ => throw new ArgumentOutOfRangeException(nameof(settingType), settingType, null) }; diff --git a/src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs b/src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs index b0784100..8fb73e13 100644 --- a/src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs +++ b/src/ConfigCatClient/Evaluation/RolloutEvaluatorExtensions.cs @@ -10,7 +10,7 @@ internal static class RolloutEvaluatorExtensions public static T Evaluate(this IRolloutEvaluator evaluator, Setting setting, string key, T defaultValue, User user, ProjectConfig remoteConfig, out EvaluationDetails evaluationDetails) { - evaluationDetails = (EvaluationDetails)evaluator.Evaluate(setting, key, defaultValue?.ToString(), user, remoteConfig, static (_, value) => EvaluationDetails.Create(value)); + evaluationDetails = (EvaluationDetails)evaluator.Evaluate(setting, key, defaultValue?.ToString(), user, remoteConfig, static (settingType, value) => EvaluationDetails.Create(settingType, value)); return evaluationDetails.Value; } diff --git a/src/ConfigCatClient/Evaluation/Setting.cs b/src/ConfigCatClient/Evaluation/Setting.cs index 838c1741..035ea4b8 100644 --- a/src/ConfigCatClient/Evaluation/Setting.cs +++ b/src/ConfigCatClient/Evaluation/Setting.cs @@ -58,7 +58,7 @@ internal class Setting #else [JsonPropertyName("t")] #endif - public SettingType SettingType { get; set; } + public SettingType SettingType { get; set; } = SettingType.Unknown; #if USE_NEWTONSOFT_JSON [JsonProperty(PropertyName = "p")] @@ -87,7 +87,8 @@ internal enum SettingType : byte Boolean = 0, String = 1, Int = 2, - Double = 3 + Double = 3, + Unknown = byte.MaxValue, } internal enum RedirectMode : byte diff --git a/src/ConfigCatClient/Extensions/ObjectExtensions.cs b/src/ConfigCatClient/Extensions/ObjectExtensions.cs index e5669c73..f0a210db 100644 --- a/src/ConfigCatClient/Extensions/ObjectExtensions.cs +++ b/src/ConfigCatClient/Extensions/ObjectExtensions.cs @@ -1,56 +1,146 @@ -using System.Runtime.CompilerServices; +using System.Diagnostics; +using System.Globalization; using ConfigCat.Client.Evaluation; +#if USE_NEWTONSOFT_JSON +using JsonValue = Newtonsoft.Json.Linq.JValue; +#else +using JsonValue = System.Text.Json.JsonElement; +#endif + namespace System { internal static class ObjectExtensions { - public static Setting ToSetting(this object value) + private static bool IsWithinAllowedIntRange(IConvertible value) { - return new Setting + // Range of Int setting types: "any whole number within the range of Int32" + // (https://configcat.com/docs/main-concepts/#about-setting-types) + + return value.GetTypeCode() switch { -#if USE_NEWTONSOFT_JSON - Value = new Newtonsoft.Json.Linq.JValue(value), -#else - Value = Text.Json.JsonSerializer.SerializeToElement(value), -#endif - SettingType = DetermineSettingType(value) + TypeCode.SByte or + TypeCode.Byte or + TypeCode.Int16 or + TypeCode.UInt16 or + TypeCode.Int32 => + true, + TypeCode.UInt32 => + value.ToUInt32(CultureInfo.InvariantCulture) is <= int.MaxValue, + TypeCode.Int64 => + value.ToInt64(CultureInfo.InvariantCulture) is >= int.MinValue and <= int.MaxValue, + TypeCode.UInt64 => + value.ToUInt64(CultureInfo.InvariantCulture) is <= int.MaxValue, + _ => + false, }; + } - SettingType DetermineSettingType(object value) - { -#if !USE_NEWTONSOFT_JSON - if (value is Text.Json.JsonElement element) - { - if (element.ValueKind == Text.Json.JsonValueKind.Number) - { - if (element.TryGetInt32(out var _) || element.TryGetInt64(out var _)) return SettingType.Int; - if (element.TryGetDouble(out var _)) return SettingType.Double; - } + private static bool IsWithinAllowedDoubleRange(IConvertible value) + { + // Range of Double setting types: "any decimal number within the range of double" + // (https://configcat.com/docs/main-concepts/#about-setting-types) - if (element.ValueKind == Text.Json.JsonValueKind.String) return SettingType.String; - if (element.ValueKind == Text.Json.JsonValueKind.True || element.ValueKind == Text.Json.JsonValueKind.False) return SettingType.Boolean; + return value.GetTypeCode() is TypeCode.Single or TypeCode.Double; + } - throw new ArgumentException($"Could not determine the setting type of {value}"); - } + public static SettingType DetermineSettingType(this JsonValue value) + { +#if USE_NEWTONSOFT_JSON + return value.Type switch + { + Newtonsoft.Json.Linq.JTokenType.String => + SettingType.String, + Newtonsoft.Json.Linq.JTokenType.Boolean => + SettingType.Boolean, + Newtonsoft.Json.Linq.JTokenType.Integer when IsWithinAllowedIntRange(value) => + SettingType.Int, + Newtonsoft.Json.Linq.JTokenType.Float when IsWithinAllowedDoubleRange(value) => + SettingType.Double, + _ => + SettingType.Unknown, + }; +#else + return value.ValueKind switch + { + Text.Json.JsonValueKind.String => + SettingType.String, + Text.Json.JsonValueKind.False or + Text.Json.JsonValueKind.True => + SettingType.Boolean, + Text.Json.JsonValueKind.Number when value.TryGetInt32(out var _) => + SettingType.Int, + Text.Json.JsonValueKind.Number when value.TryGetDouble(out var _) => + SettingType.Double, + _ => + SettingType.Unknown, + }; #endif + } - var type = value.GetType(); + public static SettingType DetermineSettingType(this object value) + { + if (value is null) + { + return SettingType.Unknown; + } - if (type == typeof(bool)) - return SettingType.Boolean; + if (value is JsonValue jsonValue) + { + return jsonValue.DetermineSettingType(); + } - if (type == typeof(int) || type == typeof(long)) - return SettingType.Int; + return Type.GetTypeCode(value.GetType()) switch + { + TypeCode.String => + SettingType.String, + TypeCode.Boolean => + SettingType.Boolean, + TypeCode.SByte or + TypeCode.Byte or + TypeCode.Int16 or + TypeCode.UInt16 or + TypeCode.Int32 or + TypeCode.UInt32 or + TypeCode.Int64 or + TypeCode.UInt64 when IsWithinAllowedIntRange((IConvertible)value) => + SettingType.Int, + TypeCode.Single or + TypeCode.Double when IsWithinAllowedDoubleRange((IConvertible)value) => + SettingType.Double, + _ => + SettingType.Unknown, + }; + } - if (type == typeof(double)) - return SettingType.Double; + public static Setting ToSetting(this object value) + { + var settingType = DetermineSettingType(value); + if (settingType == SettingType.Unknown) + { + throw new ArgumentException($"Could not determine the setting type of {value ?? "(null)"}."); + } - if (type == typeof(string)) - return SettingType.String; + return new Setting + { +#if USE_NEWTONSOFT_JSON + Value = new Newtonsoft.Json.Linq.JValue(value), +#else + Value = Text.Json.JsonSerializer.SerializeToElement(value), +#endif + SettingType = settingType + }; + } - throw new ArgumentException($"Could not determine the setting type of {value}"); - } + public static TValue ConvertTo(this JsonValue value) + { + Debug.Assert(typeof(TValue) != typeof(object), "Conversion to object is not supported."); + +#if USE_NEWTONSOFT_JSON + return Newtonsoft.Json.Linq.Extensions.Value(value); +#else + return System.Text.Json.JsonSerializer.Deserialize(value); +#endif } } } diff --git a/src/ConfigCatClient/Extensions/StringExtensions.cs b/src/ConfigCatClient/Extensions/StringExtensions.cs index ffe2a2ad..41f05c55 100644 --- a/src/ConfigCatClient/Extensions/StringExtensions.cs +++ b/src/ConfigCatClient/Extensions/StringExtensions.cs @@ -8,8 +8,16 @@ internal static class StringExtensions { public static string Hash(this string text) { - using var hash = SHA1.Create(); - var hashedBytes = hash.ComputeHash(Encoding.UTF8.GetBytes(text)); + byte[] hashedBytes; + var textBytes = Encoding.UTF8.GetBytes(text); +#if NET5_0_OR_GREATER + hashedBytes = SHA1.HashData(textBytes); +#else + using (var hash = SHA1.Create()) + { + hashedBytes = hash.ComputeHash(textBytes); + } +#endif return hashedBytes.ToHexString(); } diff --git a/src/ConfigCatClient/Extensions/TypeExtensions.cs b/src/ConfigCatClient/Extensions/TypeExtensions.cs new file mode 100644 index 00000000..47034160 --- /dev/null +++ b/src/ConfigCatClient/Extensions/TypeExtensions.cs @@ -0,0 +1,43 @@ +using ConfigCat.Client.Evaluation; + +namespace System +{ + internal static class TypeExtensions + { + public static void EnsureSupportedSettingClrType(this Type type) + { + type.ToSettingType().EnsureSupportedSettingType(isAnyAllowed: type == typeof(object)); + } + + public static void EnsureSupportedSettingType(this SettingType type, bool isAnyAllowed) + { + if (!isAnyAllowed && type == SettingType.Unknown) + { + throw new InvalidOperationException($"Only {typeof(string)}, {typeof(bool)}, {typeof(int)}, {typeof(long)}, {typeof(double)} and {typeof(object)} are supported."); + } + } + + public static SettingType ToSettingType(this Type type) + { + if (type.IsValueType && Nullable.GetUnderlyingType(type) is { } underlyingType) + { + type = underlyingType; + } + + return Type.GetTypeCode(type) switch + { + TypeCode.String => + SettingType.String, + TypeCode.Boolean => + SettingType.Boolean, + TypeCode.Int32 or + TypeCode.Int64 => + SettingType.Int, + TypeCode.Double => + SettingType.Double, + _ => + SettingType.Unknown, + }; + } + } +} diff --git a/src/ConfigCatClient/HttpConfigFetcher.cs b/src/ConfigCatClient/HttpConfigFetcher.cs index 168eb87b..d9df541b 100644 --- a/src/ConfigCatClient/HttpConfigFetcher.cs +++ b/src/ConfigCatClient/HttpConfigFetcher.cs @@ -168,7 +168,9 @@ private async ValueTask> FetchRequest(Project var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); #endif - if (!this.deserializer.TryDeserialize(responseBody, out var body)) + var httpETag = response.Headers.ETag?.Tag; + + if (!this.deserializer.TryDeserialize(responseBody, httpETag, out var body)) return Tuple.Create(response, null); if (body?.Preferences != null) diff --git a/src/ConfigCatClient/IConfigDeserializer.cs b/src/ConfigCatClient/IConfigDeserializer.cs index eb9dc8d7..66ee0d6c 100644 --- a/src/ConfigCatClient/IConfigDeserializer.cs +++ b/src/ConfigCatClient/IConfigDeserializer.cs @@ -1,10 +1,9 @@ using ConfigCat.Client.Evaluation; -using System.Collections.Generic; namespace ConfigCat.Client { internal interface IConfigDeserializer { - bool TryDeserialize(string config, out SettingsWithPreferences settings); + bool TryDeserialize(string config, string httpETag, out SettingsWithPreferences settings); } }