From 6a8a34e1a0123e1b08beae0e938386678dec8eef Mon Sep 17 00:00:00 2001 From: Adam Simon Date: Wed, 24 Jul 2024 23:18:09 +0200 Subject: [PATCH 01/14] Switch to source generated JSON serialization to allow trimming --- .../Helpers/SerializationExtensions.cs | 43 +++ src/ConfigCat.Client.Tests/OverrideTests.cs | 2 +- src/ConfigCat.Client.Tests/UtilsTests.cs | 61 +++++ .../Evaluation/RolloutEvaluator.cs | 6 +- .../Extensions/ObjectExtensions.cs | 20 +- .../Extensions/SerializationExtensions.cs | 87 ------ src/ConfigCatClient/Models/Config.cs | 2 +- .../Override/LocalFileDataSource.cs | 8 +- src/ConfigCatClient/User.cs | 20 +- .../Utils/SerializationHelper.cs | 250 ++++++++++++++++++ 10 files changed, 390 insertions(+), 109 deletions(-) create mode 100644 src/ConfigCat.Client.Tests/Helpers/SerializationExtensions.cs delete mode 100644 src/ConfigCatClient/Extensions/SerializationExtensions.cs create mode 100644 src/ConfigCatClient/Utils/SerializationHelper.cs diff --git a/src/ConfigCat.Client.Tests/Helpers/SerializationExtensions.cs b/src/ConfigCat.Client.Tests/Helpers/SerializationExtensions.cs new file mode 100644 index 00000000..f8cad0d6 --- /dev/null +++ b/src/ConfigCat.Client.Tests/Helpers/SerializationExtensions.cs @@ -0,0 +1,43 @@ +#if USE_NEWTONSOFT_JSON +using System.IO; +using Newtonsoft.Json; +#else +using System.Text.Encodings.Web; +using System.Text.Json; +#endif + +namespace System; + +internal static class SerializationExtensions +{ +#if USE_NEWTONSOFT_JSON + private static readonly JsonSerializer Serializer = JsonSerializer.Create(); +#else + private static readonly JsonSerializerOptions SerializationOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; +#endif + + // NOTE: It would be better to use ReadOnlySpan, however when the full string is wrapped in a span, json.ToString() result in a copy of the string. + // This is not the case with ReadOnlyMemory, so we use that until support for .NET 4.5 support is dropped. + public static T? Deserialize(this ReadOnlyMemory json) + { +#if USE_NEWTONSOFT_JSON + using var stringReader = new StringReader(json.ToString()); + using var reader = new JsonTextReader(stringReader); + return Serializer.Deserialize(reader); +#else + return JsonSerializer.Deserialize(json.Span); +#endif + } + + public static string Serialize(this T objectToSerialize) + { +#if USE_NEWTONSOFT_JSON + return JsonConvert.SerializeObject(objectToSerialize); +#else + return JsonSerializer.Serialize(objectToSerialize, SerializationOptions); +#endif + } +} diff --git a/src/ConfigCat.Client.Tests/OverrideTests.cs b/src/ConfigCat.Client.Tests/OverrideTests.cs index 5b43b7c6..b327aeca 100644 --- a/src/ConfigCat.Client.Tests/OverrideTests.cs +++ b/src/ConfigCat.Client.Tests/OverrideTests.cs @@ -653,7 +653,7 @@ public void OverrideValueTypeMismatchShouldBeHandledCorrectly_SimplifiedConfig(s #endif var filePath = Path.GetTempFileName(); - File.WriteAllText(filePath, $"{{ \"flags\": {{ \"{key}\": {overrideValueJson} }} }}"); + File.WriteAllText(filePath, $"{{ \"flags\": {{ \"{key}\": {overrideValueJson} }}, /* comment */ }}"); try { diff --git a/src/ConfigCat.Client.Tests/UtilsTests.cs b/src/ConfigCat.Client.Tests/UtilsTests.cs index 47d3f07d..f0c6c211 100644 --- a/src/ConfigCat.Client.Tests/UtilsTests.cs +++ b/src/ConfigCat.Client.Tests/UtilsTests.cs @@ -157,4 +157,65 @@ public void ModelHelper_SetEnum_Works(SettingType enumValue) Assert.ThrowsException(() => ModelHelper.SetEnum(ref field, enumValue)); } } + + [TestMethod] + public void SerializationHelper_SerializeUser_Works() + { + var user = new User("id") + { + Custom = + { + ["BooleanValue"] = true, + ["CharValue"] = 'c', + ["SByteValue"] = sbyte.MinValue, + ["ByteValue"] = sbyte.MaxValue, + ["Int16Value"] = short.MinValue, + ["UInt16Value"] = ushort.MaxValue, + ["Int32Value"] = int.MinValue, + ["UInt32Value"] = int.MaxValue, + ["Int64Value"] = long.MinValue, + ["UInt64Value"] = long.MaxValue, + ["SingleValue"] = 3.14f, + ["DoubleValue"] = 3.14, + ["DecimalValue"] = 3.14m, + ["DateTimeValue"] = DateTime.MaxValue, + ["DateTimeOffsetValue"] = DateTime.MaxValue, + ["TimeSpanValue"] = TimeSpan.MaxValue, + ["StringValue"] = "s", + ["GuidValue"] = Guid.Empty, + ["StringArrayValue"] = new string[] { "a", "b", "c" }, + ["DictionaryValue"] = new Dictionary { [0] = "a", [1] = "b", [2] = "c" }, + ["NestedCollectionValue"] = new object[] { false, new Dictionary { ["a"] = 0, ["b"] = new object[] { true, "c" } } }, + } + }; + + Assert.AreEqual( + """ + {"Identifier":"id","BooleanValue":true,"CharValue":"c","SByteValue":-128,"ByteValue":127,"Int16Value":-32768,"UInt16Value":65535,"Int32Value":-2147483648,"UInt32Value":2147483647,"Int64Value":-9223372036854775808,"UInt64Value":9223372036854775807,"SingleValue":3.14,"DoubleValue":3.14,"DecimalValue":3.14,"DateTimeValue":"9999-12-31T23:59:59.9999999","DateTimeOffsetValue":"9999-12-31T23:59:59.9999999","TimeSpanValue":"10675199.02:48:05.4775807","StringValue":"s","GuidValue":"00000000-0000-0000-0000-000000000000","StringArrayValue":["a","b","c"],"DictionaryValue":{"0":"a","1":"b","2":"c"},"NestedCollectionValue":[false,{"a":0,"b":[true,"c"]}]} + """, + SerializationHelper.SerializeUser(user)); + } + + [TestMethod] + public void SerializationHelper_SerializeUser_DetectsCircularReference() + { + var dictionary = new Dictionary(); + dictionary["a"] = new object[] { dictionary }; + + var user = new User("id") + { + Custom = + { + ["ArrayValue"] = new object[] { dictionary }, + } + }; + +#if NET45 + var ex = Assert.ThrowsException(() => SerializationHelper.SerializeUser(user)); + StringAssert.StartsWith(ex.Message, "Self referencing loop detected"); +#else + var ex = Assert.ThrowsException(() => SerializationHelper.SerializeUser(user)); + StringAssert.StartsWith(ex.Message, "A circular reference was detected"); +#endif + } } diff --git a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs index feaf47d1..5c3ff36b 100644 --- a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs +++ b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs @@ -36,7 +36,7 @@ public EvaluateResult Evaluate(T defaultValue, ref EvaluateContext context, [ if (context.User is not null) { - logBuilder.Append($" for User '{context.User.GetAllAttributes().Serialize()}'"); + logBuilder.Append($" for User '{SerializationHelper.SerializeUser(context.User)}'"); } logBuilder.IncreaseIndent(); @@ -882,7 +882,7 @@ private static string UserAttributeValueToString(object attributeValue) } else if (attributeValue is string[] stringArray) { - return stringArray.Serialize(unescapeAstral: true); + return SerializationHelper.SerializeStringArray(stringArray, unescapeAstral: true); } else if (attributeValue.TryConvertNumericToDouble(out var number)) { @@ -958,7 +958,7 @@ private double GetUserAttributeValueAsUnixTimeSeconds(string attributeName, obje private string[]? GetUserAttributeValueAsStringArray(string attributeName, object attributeValue, UserCondition condition, string key, out string? error) { if (attributeValue is string[] stringArray - || attributeValue is string json && (stringArray = json.AsMemory().DeserializeOrDefault()!) is not null) + || attributeValue is string json && (stringArray = SerializationHelper.DeserializeStringArray(json.AsMemory(), throwOnError: false)!) is not null) { if (!Array.Exists(stringArray, item => item is null)) { diff --git a/src/ConfigCatClient/Extensions/ObjectExtensions.cs b/src/ConfigCatClient/Extensions/ObjectExtensions.cs index 49800c2c..4e8a15c3 100644 --- a/src/ConfigCatClient/Extensions/ObjectExtensions.cs +++ b/src/ConfigCatClient/Extensions/ObjectExtensions.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Globalization; using ConfigCat.Client; +using ConfigCat.Client.Utils; #if USE_NEWTONSOFT_JSON using JsonValue = Newtonsoft.Json.Linq.JValue; @@ -70,19 +71,19 @@ internal static SettingValue ToSettingValue(this JsonValue value, out SettingTyp { case Text.Json.JsonValueKind.String: settingType = SettingType.String; - return new SettingValue { StringValue = value.ConvertTo() }; + return new SettingValue { StringValue = value.ConvertTo(SourceGenSerializationContext.Default.String) }; case Text.Json.JsonValueKind.False or Text.Json.JsonValueKind.True: settingType = SettingType.Boolean; - return new SettingValue { BoolValue = value.ConvertTo() }; + return new SettingValue { BoolValue = value.ConvertTo(SourceGenSerializationContext.Default.Boolean) }; case Text.Json.JsonValueKind.Number when value.TryGetInt32(out var _): settingType = SettingType.Int; - return new SettingValue { IntValue = value.ConvertTo() }; + return new SettingValue { IntValue = value.ConvertTo(SourceGenSerializationContext.Default.Int32) }; case Text.Json.JsonValueKind.Number when value.TryGetDouble(out var _): settingType = SettingType.Double; - return new SettingValue { DoubleValue = value.ConvertTo() }; + return new SettingValue { DoubleValue = value.ConvertTo(SourceGenSerializationContext.Default.Double) }; } #endif @@ -136,18 +137,21 @@ public static Setting ToSetting(this object? value) return setting; } +#if USE_NEWTONSOFT_JSON private static TValue ConvertTo(this JsonValue value) { Debug.Assert(typeof(TValue) != typeof(object), "Conversion to object is not supported."); - -#if USE_NEWTONSOFT_JSON Debug.Assert(value.Type != Newtonsoft.Json.Linq.JTokenType.Null, "Tried to convert unexpected null value."); return Newtonsoft.Json.Linq.Extensions.Value(value)!; + } #else + private static TValue ConvertTo(this JsonValue value, Text.Json.Serialization.Metadata.JsonTypeInfo jsonTypeInfo) + { + Debug.Assert(typeof(TValue) != typeof(object), "Conversion to object is not supported."); Debug.Assert(value.ValueKind != Text.Json.JsonValueKind.Null, "Tried to convert unexpected null value."); - return Text.Json.JsonSerializer.Deserialize(value)!; -#endif + return Text.Json.JsonSerializer.Deserialize(value, jsonTypeInfo)!; } +#endif public static bool TryConvertNumericToDouble(this object value, out double number) { diff --git a/src/ConfigCatClient/Extensions/SerializationExtensions.cs b/src/ConfigCatClient/Extensions/SerializationExtensions.cs deleted file mode 100644 index b325b0f9..00000000 --- a/src/ConfigCatClient/Extensions/SerializationExtensions.cs +++ /dev/null @@ -1,87 +0,0 @@ -#if USE_NEWTONSOFT_JSON -using System.IO; -using Newtonsoft.Json; -#else -using System.Globalization; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.RegularExpressions; -#endif - -namespace System; - -internal static class SerializationExtensions -{ -#if USE_NEWTONSOFT_JSON - private static readonly JsonSerializer Serializer = JsonSerializer.Create(); -#else - private static readonly JsonSerializerOptions TolerantSerializerOptions = new() - { - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; -#endif - - // NOTE: It would be better to use ReadOnlySpan, however when the full string is wrapped in a span, json.ToString() result in a copy of the string. - // This is not the case with ReadOnlyMemory, so we use that until support for .NET 4.5 support is dropped. - public static T? Deserialize(this ReadOnlyMemory json, bool tolerant = false) - { -#if USE_NEWTONSOFT_JSON - using var stringReader = new StringReader(json.ToString()); - using var reader = new JsonTextReader(stringReader); - return Serializer.Deserialize(reader); -#else - return JsonSerializer.Deserialize(json.Span, tolerant ? TolerantSerializerOptions : null); -#endif - } - - public static T? DeserializeOrDefault(this ReadOnlyMemory json, bool tolerant = false) - { - try - { - return json.Deserialize(tolerant); - } - catch - { - return default; - } - } - - public static string Serialize(this T objectToSerialize, bool unescapeAstral = false) - { -#if USE_NEWTONSOFT_JSON - return JsonConvert.SerializeObject(objectToSerialize); -#else - var json = JsonSerializer.Serialize(objectToSerialize, TolerantSerializerOptions); - if (unescapeAstral) - { - // NOTE: There's no easy way to configure System.Text.Json not to encode surrogate pairs (i.e. Unicode code points above U+FFFF). - // The only way of doing it during serialization (https://github.com/dotnet/runtime/issues/54193#issuecomment-861155179) needs unsafe code, - // which we want to avoid in this project. So, we resort to the following regex-based workaround: - json = Regex.Replace(json, @"\\u[dD][89abAB][0-9a-fA-F]{2}\\u[dD][c-fC-F][0-9a-fA-F]{2}", match => - { - // Ignore possible matches that aren't really escaped ('\\uD800\uDC00', '\\\\uD800\uDC00', etc.) - var isEscaped = true; - for (var i = match.Index - 1; i >= 0; i--) - { - if (json[i] != '\\') - { - break; - } - isEscaped = !isEscaped; - } - if (!isEscaped) - { - return match.Value; - } - - var highSurrogate = ushort.Parse(match.Value.AsSpan(2, 4).ToParsable(), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); - var lowSurrogate = ushort.Parse(match.Value.AsSpan(8, 4).ToParsable(), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); - return char.ConvertFromUtf32(char.ConvertToUtf32((char)highSurrogate, (char)lowSurrogate)); - }, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(5)); - } - return json; -#endif - } -} diff --git a/src/ConfigCatClient/Models/Config.cs b/src/ConfigCatClient/Models/Config.cs index 37cebd0d..4337510a 100644 --- a/src/ConfigCatClient/Models/Config.cs +++ b/src/ConfigCatClient/Models/Config.cs @@ -42,7 +42,7 @@ internal sealed class Config : IConfig { public static Config Deserialize(ReadOnlyMemory configJson, bool tolerant = false) { - return configJson.Deserialize(tolerant) + return SerializationHelper.DeserializeConfig(configJson, tolerant) ?? throw new ArgumentException("Invalid config JSON content: " + configJson, nameof(configJson)); } diff --git a/src/ConfigCatClient/Override/LocalFileDataSource.cs b/src/ConfigCatClient/Override/LocalFileDataSource.cs index 45fd9897..d80b295e 100644 --- a/src/ConfigCatClient/Override/LocalFileDataSource.cs +++ b/src/ConfigCatClient/Override/LocalFileDataSource.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using ConfigCat.Client.Utils; namespace ConfigCat.Client.Override; @@ -104,7 +105,7 @@ private async Task ReloadFileAsync(bool isAsync, CancellationToken cancellationT try { var content = File.ReadAllText(this.fullPath); - var simplified = content.AsMemory().DeserializeOrDefault(tolerant: true); + var simplified = SerializationHelper.DeserializeSimplifiedConfig(content.AsMemory(), tolerant: true, throwOnError: false); if (simplified?.Entries is not null) { this.overrideValues = simplified.Entries.ToDictionary(kv => kv.Key, kv => kv.Value.ToSetting()); @@ -152,13 +153,14 @@ public void Dispose() this.cancellationTokenSource.Cancel(); } - private sealed class SimplifiedConfig + internal sealed class SimplifiedConfig { #if USE_NEWTONSOFT_JSON [Newtonsoft.Json.JsonProperty(PropertyName = "flags")] + public Dictionary? Entries { get; set; } #else [System.Text.Json.Serialization.JsonPropertyName("flags")] + public Dictionary? Entries { get; set; } #endif - public Dictionary? Entries { get; set; } } } diff --git a/src/ConfigCatClient/User.cs b/src/ConfigCatClient/User.cs index 3b6b0afc..ae29bfe9 100644 --- a/src/ConfigCatClient/User.cs +++ b/src/ConfigCatClient/User.cs @@ -93,20 +93,20 @@ public IDictionary Custom /// /// Returns all attributes of the user. /// - public IReadOnlyDictionary GetAllAttributes() + internal Dictionary GetAllAttributes(Func convertValue) { - var result = new Dictionary(); + var result = new Dictionary(); - result[nameof(Identifier)] = Identifier; + result[nameof(Identifier)] = convertValue(Identifier); if (Email is not null) { - result[nameof(Email)] = Email; + result[nameof(Email)] = convertValue(Email); } if (Country is not null) { - result[nameof(Country)] = Country; + result[nameof(Country)] = convertValue(Country); } if (this.custom is { Count: > 0 }) @@ -115,7 +115,7 @@ public IReadOnlyDictionary GetAllAttributes() { if (item.Value is not null && item.Key is not (nameof(Identifier) or nameof(Email) or nameof(Country))) { - result.Add(item.Key, item.Value); + result.Add(item.Key, convertValue(item.Value)); } } } @@ -123,6 +123,14 @@ public IReadOnlyDictionary GetAllAttributes() return result; } + /// + /// Returns all attributes of the user. + /// + public IReadOnlyDictionary GetAllAttributes() + { + return GetAllAttributes(value => value); + } + /// /// Initializes a new instance of the class. /// diff --git a/src/ConfigCatClient/Utils/SerializationHelper.cs b/src/ConfigCatClient/Utils/SerializationHelper.cs new file mode 100644 index 00000000..b2754e71 --- /dev/null +++ b/src/ConfigCatClient/Utils/SerializationHelper.cs @@ -0,0 +1,250 @@ +using System; +using ConfigCat.Client.Override; + +#if USE_NEWTONSOFT_JSON +using System.IO; +using Newtonsoft.Json; +#else +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +#endif + +namespace ConfigCat.Client.Utils; + +#if !USE_NEWTONSOFT_JSON +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(Config))] +[JsonSerializable(typeof(LocalFileDataSource.SimplifiedConfig))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(Dictionary))] +internal partial class SourceGenSerializationContext : JsonSerializerContext +{ + // Implemented by System.Text.Json source generator. + // See also: + // * https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-source-generator/ + // * https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/source-generation +} +#endif + +internal static class SerializationHelper +{ +#if USE_NEWTONSOFT_JSON + private static readonly JsonSerializer Serializer = JsonSerializer.Create(); + + private static T? Deserialize(ReadOnlyMemory json) + { + using var stringReader = new StringReader(json.ToString()); + using var reader = new JsonTextReader(stringReader); + return Serializer.Deserialize(reader); + } +#else + private static readonly SourceGenSerializationContext TolerantSerializationContext = new SourceGenSerializationContext(new JsonSerializerOptions + { + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); +#endif + + // NOTE: It would be better to use ReadOnlySpan, however when the full string is wrapped in a span, json.ToString() result in a copy of the string. + // This is not the case with ReadOnlyMemory, so we use that until support for .NET 4.5 support is dropped. + + public static Config? DeserializeConfig(ReadOnlyMemory json, bool tolerant = false, bool throwOnError = true) + { + try + { +#if USE_NEWTONSOFT_JSON + return Deserialize(json); +#else + return JsonSerializer.Deserialize(json.Span, tolerant ? TolerantSerializationContext.Config : SourceGenSerializationContext.Default.Config); +#endif + } + catch when (!throwOnError) + { + return default; + } + } + + public static LocalFileDataSource.SimplifiedConfig? DeserializeSimplifiedConfig(ReadOnlyMemory json, bool tolerant = false, bool throwOnError = true) + { + try + { +#if USE_NEWTONSOFT_JSON + return Deserialize(json); +#else + return JsonSerializer.Deserialize(json.Span, tolerant ? TolerantSerializationContext.SimplifiedConfig : SourceGenSerializationContext.Default.SimplifiedConfig); +#endif + } + catch when (!throwOnError) + { + return default; + } + } + + public static string[]? DeserializeStringArray(ReadOnlyMemory json, bool tolerant = false, bool throwOnError = true) + { + try + { +#if USE_NEWTONSOFT_JSON + return Deserialize(json); +#else + return JsonSerializer.Deserialize(json.Span, tolerant ? TolerantSerializationContext.StringArray : SourceGenSerializationContext.Default.StringArray); +#endif + } + catch when (!throwOnError) + { + return default; + } + } + + public static string SerializeStringArray(string[] obj, bool unescapeAstral = false) + { +#if USE_NEWTONSOFT_JSON + return JsonConvert.SerializeObject(obj); +#else + var json = JsonSerializer.Serialize(obj, TolerantSerializationContext.StringArray); + return unescapeAstral ? UnescapeAstralCodePoints(json) : json; +#endif + } + + public static string SerializeUser(User obj, bool unescapeAstral = false) + { +#if USE_NEWTONSOFT_JSON + return JsonConvert.SerializeObject(obj.GetAllAttributes()); +#else + // NOTE: When using System.Text.Json source generation, polymorphic types can't be serialized unless + // all the possible concrete type are listed using JsonSerializableAttribute. + // However, we allow consumers to pass values of any type in custom user attributes, so obviously + // there is no way to list all the possible types. As a best effort, we can approximate the output of + // the non-source generated serialization by building a JSON DOM and serializing that. + var attributes = obj.GetAllAttributes(value => + { + HashSet? visitedCollections = null; + return UnknownValueToJsonNode(value, ref visitedCollections); + }); + + var json = JsonSerializer.Serialize(attributes!, TolerantSerializationContext.DictionaryStringJsonNode); + return unescapeAstral ? UnescapeAstralCodePoints(json) : json; +#endif + } + +#if !USE_NEWTONSOFT_JSON + private static JsonNode? UnknownValueToJsonNode(object? value, ref HashSet? visitedCollections) + { + if (value is null) + { + return null; + } + + switch (Type.GetTypeCode(value.GetType())) + { + case TypeCode.Boolean: return JsonValue.Create((bool)value); + case TypeCode.Char: return JsonValue.Create((char)value); + case TypeCode.SByte: return JsonValue.Create((sbyte)value); + case TypeCode.Byte: return JsonValue.Create((byte)value); + case TypeCode.Int16: return JsonValue.Create((short)value); + case TypeCode.UInt16: return JsonValue.Create((ushort)value); + case TypeCode.Int32: return JsonValue.Create((int)value); + case TypeCode.UInt32: return JsonValue.Create((uint)value); + case TypeCode.Int64: return JsonValue.Create((long)value); + case TypeCode.UInt64: return JsonValue.Create((ulong)value); +#if NETCOREAPP + case TypeCode.Single: return JsonValue.Create((float)value); + case TypeCode.Double: return JsonValue.Create((double)value); +#else + // On .NET Framework, System.Text.Json serializes float and double values incorrectly. + // E.g. 3.14 -> 3.1400000000000001. We can workaround this by casting such values to decimal. + case TypeCode.Single: return JsonValue.Create((decimal)(float)value); + case TypeCode.Double: return JsonValue.Create((decimal)(double)value); +#endif + case TypeCode.Decimal: return JsonValue.Create((decimal)value); + case TypeCode.DateTime: return JsonValue.Create((DateTime)value); + case TypeCode.String: return JsonValue.Create((string)value); + } + + if (value is DateTimeOffset dateTimeOffset) + { + return JsonValue.Create(dateTimeOffset); + } + + if (value is Guid guid) + { + return JsonValue.Create(guid); + } + + if (value is IEnumerable enumerable) + { + visitedCollections ??= new HashSet(); + if (!visitedCollections.Add(enumerable)) + { + // NOTE: We need to check for circular references because that would result in a StackOverflowException, which would bring down the process. + throw new InvalidOperationException("A circular reference was detected in the serialized object graph."); + } + + JsonNode jsonNode; + if (value is IDictionary dictionary) + { + var jsonObject = new JsonObject(); + var enumerator = dictionary.GetEnumerator(); + using (enumerator as IDisposable) + { + while (enumerator.MoveNext()) + { + var entry = enumerator.Entry; + jsonObject.Add(entry.Key?.ToString() ?? "", UnknownValueToJsonNode(entry.Value, ref visitedCollections)); + } + } + jsonNode = jsonObject; + } + else + { + var jsonArray = new JsonArray(); + foreach (var item in enumerable) + { + jsonArray.Add(UnknownValueToJsonNode(item, ref visitedCollections)); + } + jsonNode = jsonArray; + } + + visitedCollections!.Remove(enumerable); + return jsonNode; + } + + return Convert.ToString(value, CultureInfo.InvariantCulture); + } + + private static string UnescapeAstralCodePoints(string json) + { + // NOTE: There's no easy way to configure System.Text.Json not to encode surrogate pairs (i.e. Unicode code points above U+FFFF). + // The only way of doing it during serialization (https://github.com/dotnet/runtime/issues/54193#issuecomment-861155179) needs unsafe code, + // which we want to avoid in this project. So, we resort to the following regex-based workaround: + return Regex.Replace(json, @"\\u[dD][89abAB][0-9a-fA-F]{2}\\u[dD][c-fC-F][0-9a-fA-F]{2}", match => + { + // Ignore possible matches that aren't really escaped ('\\uD800\uDC00', '\\\\uD800\uDC00', etc.) + var isEscaped = true; + for (var i = match.Index - 1; i >= 0; i--) + { + if (json[i] != '\\') + { + break; + } + isEscaped = !isEscaped; + } + if (!isEscaped) + { + return match.Value; + } + + var highSurrogate = ushort.Parse(match.Value.AsSpan(2, 4).ToParsable(), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + var lowSurrogate = ushort.Parse(match.Value.AsSpan(8, 4).ToParsable(), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + return char.ConvertFromUtf32(char.ConvertToUtf32((char)highSurrogate, (char)lowSurrogate)); + }, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(5)); + } +#endif + } From afe22e14f25f6d4cac4dfa0e2c2ad02e872ca5f6 Mon Sep 17 00:00:00 2001 From: Adam Simon Date: Thu, 25 Jul 2024 23:36:27 +0200 Subject: [PATCH 02/14] Switch to source generated regexes --- src/ConfigCatClient/ConfigCatClient.csproj | 4 +-- .../Utils/SerializationHelper.cs | 14 ++++++-- src/ConfigCatClient/Versioning/SemVersion.cs | 36 ++++++++++++------- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/ConfigCatClient/ConfigCatClient.csproj b/src/ConfigCatClient/ConfigCatClient.csproj index 92c695aa..35a6284f 100644 --- a/src/ConfigCatClient/ConfigCatClient.csproj +++ b/src/ConfigCatClient/ConfigCatClient.csproj @@ -1,6 +1,6 @@ - net45;net461;netstandard2.0;netstandard2.1;net5.0;net6.0 + net45;net461;netstandard2.0;netstandard2.1;net5.0;net6.0;net8.0 ConfigCat.Client ConfigCat.Client true @@ -14,7 +14,7 @@ https://configcat.com/docs/sdk-reference/dotnet https://github.com/ConfigCat/.net-sdk git - 10.0 + 12.0 enable nullable https://github.com/configcat/.net-sdk/releases diff --git a/src/ConfigCatClient/Utils/SerializationHelper.cs b/src/ConfigCatClient/Utils/SerializationHelper.cs index b2754e71..3394f265 100644 --- a/src/ConfigCatClient/Utils/SerializationHelper.cs +++ b/src/ConfigCatClient/Utils/SerializationHelper.cs @@ -32,7 +32,7 @@ internal partial class SourceGenSerializationContext : JsonSerializerContext } #endif -internal static class SerializationHelper +internal static partial class SerializationHelper { #if USE_NEWTONSOFT_JSON private static readonly JsonSerializer Serializer = JsonSerializer.Create(); @@ -219,12 +219,20 @@ public static string SerializeUser(User obj, bool unescapeAstral = false) return Convert.ToString(value, CultureInfo.InvariantCulture); } +#if NET7_0_OR_GREATER + [GeneratedRegex(@"\\u[dD][89abAB][0-9a-fA-F]{2}\\u[dD][c-fC-F][0-9a-fA-F]{2}", RegexOptions.CultureInvariant, 5000)] + private static partial Regex EscapedSurrogatePairsRegex(); +#else + private static readonly Regex EscapedSurrogatePairsRegexCached = new Regex(@"\\u[dD][89abAB][0-9a-fA-F]{2}\\u[dD][c-fC-F][0-9a-fA-F]{2}", RegexOptions.Compiled | RegexOptions.CultureInvariant, TimeSpan.FromSeconds(5)); + private static Regex EscapedSurrogatePairsRegex() => EscapedSurrogatePairsRegexCached; +#endif + private static string UnescapeAstralCodePoints(string json) { // NOTE: There's no easy way to configure System.Text.Json not to encode surrogate pairs (i.e. Unicode code points above U+FFFF). // The only way of doing it during serialization (https://github.com/dotnet/runtime/issues/54193#issuecomment-861155179) needs unsafe code, // which we want to avoid in this project. So, we resort to the following regex-based workaround: - return Regex.Replace(json, @"\\u[dD][89abAB][0-9a-fA-F]{2}\\u[dD][c-fC-F][0-9a-fA-F]{2}", match => + return EscapedSurrogatePairsRegex().Replace(json, match => { // Ignore possible matches that aren't really escaped ('\\uD800\uDC00', '\\\\uD800\uDC00', etc.) var isEscaped = true; @@ -244,7 +252,7 @@ private static string UnescapeAstralCodePoints(string json) var highSurrogate = ushort.Parse(match.Value.AsSpan(2, 4).ToParsable(), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); var lowSurrogate = ushort.Parse(match.Value.AsSpan(8, 4).ToParsable(), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); return char.ConvertFromUtf32(char.ConvertToUtf32((char)highSurrogate, (char)lowSurrogate)); - }, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(5)); + }); } #endif } diff --git a/src/ConfigCatClient/Versioning/SemVersion.cs b/src/ConfigCatClient/Versioning/SemVersion.cs index 085b3876..545fcaab 100644 --- a/src/ConfigCatClient/Versioning/SemVersion.cs +++ b/src/ConfigCatClient/Versioning/SemVersion.cs @@ -42,21 +42,33 @@ namespace ConfigCat.Client.Versioning; public sealed class SemVersion : IEquatable, IComparable, IComparable #else [Serializable] -public sealed class SemVersion : IEquatable, IComparable, IComparable, ISerializable +public sealed partial class SemVersion : IEquatable, IComparable, IComparable, ISerializable #endif { - private static readonly Regex ParseEx = - new Regex(@"^(?\d+)" + - @"(?>\.(?\d+))?" + - @"(?>\.(?\d+))?" + - @"(?>\-(?
[0-9A-Za-z\-\.]+))?" +
-            @"(?>\+(?[0-9A-Za-z\-\.]+))?$",
+#if NET7_0_OR_GREATER
+    [GeneratedRegex(@"^(?\d+)" +
+        @"(?>\.(?\d+))?" +
+        @"(?>\.(?\d+))?" +
+        @"(?>\-(?
[0-9A-Za-z\-\.]+))?" +
+        @"(?>\+(?[0-9A-Za-z\-\.]+))?$",
+        RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture,
+        500)]
+    private static partial Regex ParseEx();
+#else
+    private static readonly Regex ParseExCached = new Regex(@"^(?\d+)" +
+        @"(?>\.(?\d+))?" +
+        @"(?>\.(?\d+))?" +
+        @"(?>\-(?
[0-9A-Za-z\-\.]+))?" +
+        @"(?>\+(?[0-9A-Za-z\-\.]+))?$",
 #if NETSTANDARD
-            RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture,
+        RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture,
 #else
-            RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture,
+        RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture,
+#endif
+        TimeSpan.FromSeconds(0.5));
+
+    private static Regex ParseEx() => ParseExCached;
 #endif
-            TimeSpan.FromSeconds(0.5));
 
 #if !NETSTANDARD
 #pragma warning disable CA1801 // Parameter unused
@@ -134,7 +146,7 @@ public SemVersion(Version version)
     /// The Major, Minor, or Patch versions are larger than int.MaxValue.
     public static SemVersion Parse(string version, bool strict = false)
     {
-        var match = ParseEx.Match(version);
+        var match = ParseEx().Match(version);
         if (!match.Success)
             throw new ArgumentException("Invalid version.", nameof(version));
 
@@ -176,7 +188,7 @@ public static bool TryParse(string version, out SemVersion semver, bool strict =
         semver = null;
         if (version is null) return false;
 
-        var match = ParseEx.Match(version);
+        var match = ParseEx().Match(version);
         if (!match.Success) return false;
 
         if (!int.TryParse(match.Groups["major"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var major))

From 278f94defaa02623829c45193d2d48fc51948b05 Mon Sep 17 00:00:00 2001
From: Adam Simon 
Date: Wed, 24 Jul 2024 23:24:59 +0200
Subject: [PATCH 03/14] Make SDK trimmable and AOT-friendly

---
 .gitattributes                                        |  3 +++
 .../ConfigCat.Client.Benchmarks.csproj                |  4 ++--
 benchmarks/NewVersionLib/NewVersionLib.csproj         |  4 ++--
 benchmarks/OldVersionLib/OldVersionLib.csproj         |  4 ++--
 .../ASP.NETCore/WebApplication/WebApplication.csproj  |  2 +-
 samples/ConsoleApp/ConsoleApp.csproj                  |  7 +++++--
 samples/ConsoleApp/README.md                          | 11 ++++++++++-
 samples/ConsoleApp/build-aot.cmd                      |  3 +++
 samples/ConsoleApp/build-aot.sh                       |  3 +++
 .../ConfigCat.Client.Tests.csproj                     |  2 +-
 src/ConfigCatClient/ConfigCatClient.csproj            |  3 +++
 11 files changed, 35 insertions(+), 11 deletions(-)
 create mode 100644 samples/ConsoleApp/build-aot.cmd
 create mode 100644 samples/ConsoleApp/build-aot.sh

diff --git a/.gitattributes b/.gitattributes
index 1ff0c423..ca8cb466 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -61,3 +61,6 @@
 #*.PDF   diff=astextplain
 #*.rtf   diff=astextplain
 #*.RTF   diff=astextplain
+
+# Make sure unix eol in shell scripts
+*.sh      eol=lf
diff --git a/benchmarks/ConfigCat.Client.Benchmarks/ConfigCat.Client.Benchmarks.csproj b/benchmarks/ConfigCat.Client.Benchmarks/ConfigCat.Client.Benchmarks.csproj
index d6560026..2810735d 100644
--- a/benchmarks/ConfigCat.Client.Benchmarks/ConfigCat.Client.Benchmarks.csproj
+++ b/benchmarks/ConfigCat.Client.Benchmarks/ConfigCat.Client.Benchmarks.csproj
@@ -2,8 +2,8 @@
 
   
     Exe
-    net48;net6.0
-    10.0
+    net48;net8.0
+    12.0
     enable
     nullable
     true
diff --git a/benchmarks/NewVersionLib/NewVersionLib.csproj b/benchmarks/NewVersionLib/NewVersionLib.csproj
index d5434022..a917a5d8 100644
--- a/benchmarks/NewVersionLib/NewVersionLib.csproj
+++ b/benchmarks/NewVersionLib/NewVersionLib.csproj
@@ -3,8 +3,8 @@
   
     ConfigCatClientBenchmarks
     ConfigCat.Client.Benchmarks.New
-    net48;net6.0
-    10.0
+    net48;net8.0
+    12.0
     enable
     nullable
     true
diff --git a/benchmarks/OldVersionLib/OldVersionLib.csproj b/benchmarks/OldVersionLib/OldVersionLib.csproj
index 001c5e43..7c7c6a7f 100644
--- a/benchmarks/OldVersionLib/OldVersionLib.csproj
+++ b/benchmarks/OldVersionLib/OldVersionLib.csproj
@@ -5,8 +5,8 @@
          to which the interals are made visible by ConfigCat.Client. -->
     ConfigCatClientTests
     ConfigCat.Client.Benchmarks.New
-    net48;net6.0
-    10.0
+    net48;net8.0
+    12.0
     enable
     nullable
     true
diff --git a/samples/ASP.NETCore/WebApplication/WebApplication.csproj b/samples/ASP.NETCore/WebApplication/WebApplication.csproj
index 1eaeb5ac..45766e54 100644
--- a/samples/ASP.NETCore/WebApplication/WebApplication.csproj
+++ b/samples/ASP.NETCore/WebApplication/WebApplication.csproj
@@ -8,7 +8,7 @@
   
     
     
-    
+    
     
     
   
diff --git a/samples/ConsoleApp/ConsoleApp.csproj b/samples/ConsoleApp/ConsoleApp.csproj
index 9213c559..87f96bca 100644
--- a/samples/ConsoleApp/ConsoleApp.csproj
+++ b/samples/ConsoleApp/ConsoleApp.csproj
@@ -2,14 +2,17 @@
 
   
     Exe
-    net6.0
+    net8.0
     enable
+    true
+    false
+    true
   
 
   
       
     
-    
+    
   
 
 
diff --git a/samples/ConsoleApp/README.md b/samples/ConsoleApp/README.md
index b05dfe40..64e1393d 100644
--- a/samples/ConsoleApp/README.md
+++ b/samples/ConsoleApp/README.md
@@ -7,4 +7,13 @@ This is a simple .NET Core Console application to demonstrate how to use the Con
 2. Run sample app:
 ```bash
 dotnet run
-```
\ No newline at end of file
+```
+
+## Ahead-of-time (AOT) compilation
+
+The sample app also demonstrates that the ConfigCat SDK can be used in .NET 8+ applications compiled to native code using [Native AOT](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/).
+
+1. Make sure you have [the prerequisites](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/#prerequisites) installed in your development environment.
+2. Execute the build script corresponding to your OS (`build-aot.cmd` on Windows, `build-aot.sh` on Linux).
+3. Locate the executable in the publish output directory (`bin/Release/net8.0/win-x64/native` on Windows, `bin/Release/net8.0/linux-x64/native` on Linux).
+4. Run the executable.
\ No newline at end of file
diff --git a/samples/ConsoleApp/build-aot.cmd b/samples/ConsoleApp/build-aot.cmd
new file mode 100644
index 00000000..16123f3c
--- /dev/null
+++ b/samples/ConsoleApp/build-aot.cmd
@@ -0,0 +1,3 @@
+@ECHO OFF
+
+dotnet publish -c Release -f net8.0 -r win-x64
\ No newline at end of file
diff --git a/samples/ConsoleApp/build-aot.sh b/samples/ConsoleApp/build-aot.sh
new file mode 100644
index 00000000..9e3d750a
--- /dev/null
+++ b/samples/ConsoleApp/build-aot.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+
+dotnet publish -c Release -f net8.0 -r linux-x64
\ No newline at end of file
diff --git a/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj b/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj
index 3195e042..c9e9b703 100644
--- a/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj
+++ b/src/ConfigCat.Client.Tests/ConfigCat.Client.Tests.csproj
@@ -5,7 +5,7 @@
     false
     ConfigCatClientTests
     true
-    10.0
+    12.0
     enable
     nullable
     CS0618
diff --git a/src/ConfigCatClient/ConfigCatClient.csproj b/src/ConfigCatClient/ConfigCatClient.csproj
index 35a6284f..fa1df11c 100644
--- a/src/ConfigCatClient/ConfigCatClient.csproj
+++ b/src/ConfigCatClient/ConfigCatClient.csproj
@@ -38,6 +38,9 @@
     true
     snupkg
     true
+    
+    true
+    true
   
 
   

From 7f19628d944b9c7c0f7f0924bd6898cad76bd40a Mon Sep 17 00:00:00 2001
From: Adam Simon 
Date: Thu, 25 Jul 2024 18:10:26 +0200
Subject: [PATCH 04/14] Add Blazor Wasm sample app

---
 .gitignore                                    |   1 +
 samples/BlazorWasm/App.razor                  |  12 ++
 samples/BlazorWasm/BlazorWasm.csproj          |  27 +++++
 samples/BlazorWasm/BlazorWasm.sln             |  41 +++++++
 samples/BlazorWasm/Layout/MainLayout.razor    |  16 +++
 .../BlazorWasm/Layout/MainLayout.razor.css    |  77 +++++++++++++
 samples/BlazorWasm/Layout/NavMenu.razor       |  29 +++++
 samples/BlazorWasm/Layout/NavMenu.razor.css   |  83 ++++++++++++++
 samples/BlazorWasm/Pages/Home.razor           |  23 ++++
 samples/BlazorWasm/Pages/Home.razor.cs        |  27 +++++
 samples/BlazorWasm/Pages/Home.razor.css       |  15 +++
 samples/BlazorWasm/Program.cs                 |  28 +++++
 samples/BlazorWasm/README.md                  |  26 +++++
 samples/BlazorWasm/_Imports.razor             |  10 ++
 samples/BlazorWasm/build-aot.cmd              |   3 +
 samples/BlazorWasm/build-aot.sh               |   3 +
 samples/BlazorWasm/libman.json                |  12 ++
 samples/BlazorWasm/wwwroot/css/app.css        | 103 ++++++++++++++++++
 samples/BlazorWasm/wwwroot/favicon.png        | Bin 0 -> 1148 bytes
 samples/BlazorWasm/wwwroot/icon-192.png       | Bin 0 -> 2626 bytes
 samples/BlazorWasm/wwwroot/index.html         |  32 ++++++
 21 files changed, 568 insertions(+)
 create mode 100644 samples/BlazorWasm/App.razor
 create mode 100644 samples/BlazorWasm/BlazorWasm.csproj
 create mode 100644 samples/BlazorWasm/BlazorWasm.sln
 create mode 100644 samples/BlazorWasm/Layout/MainLayout.razor
 create mode 100644 samples/BlazorWasm/Layout/MainLayout.razor.css
 create mode 100644 samples/BlazorWasm/Layout/NavMenu.razor
 create mode 100644 samples/BlazorWasm/Layout/NavMenu.razor.css
 create mode 100644 samples/BlazorWasm/Pages/Home.razor
 create mode 100644 samples/BlazorWasm/Pages/Home.razor.cs
 create mode 100644 samples/BlazorWasm/Pages/Home.razor.css
 create mode 100644 samples/BlazorWasm/Program.cs
 create mode 100644 samples/BlazorWasm/README.md
 create mode 100644 samples/BlazorWasm/_Imports.razor
 create mode 100644 samples/BlazorWasm/build-aot.cmd
 create mode 100644 samples/BlazorWasm/build-aot.sh
 create mode 100644 samples/BlazorWasm/libman.json
 create mode 100644 samples/BlazorWasm/wwwroot/css/app.css
 create mode 100644 samples/BlazorWasm/wwwroot/favicon.png
 create mode 100644 samples/BlazorWasm/wwwroot/icon-192.png
 create mode 100644 samples/BlazorWasm/wwwroot/index.html

diff --git a/.gitignore b/.gitignore
index b79da535..a16d2417 100644
--- a/.gitignore
+++ b/.gitignore
@@ -295,3 +295,4 @@ coverage.xml
 /format-report.json
 src/.idea
 /samples/ASP.NETCore/WebApplication/wwwroot/lib/
+/samples/BlazorWasm/wwwroot/lib/
diff --git a/samples/BlazorWasm/App.razor b/samples/BlazorWasm/App.razor
new file mode 100644
index 00000000..6fd3ed1b
--- /dev/null
+++ b/samples/BlazorWasm/App.razor
@@ -0,0 +1,12 @@
+
+    
+        
+        
+    
+    
+        Not found
+        
+            

Sorry, there's nothing at this address.

+
+
+
diff --git a/samples/BlazorWasm/BlazorWasm.csproj b/samples/BlazorWasm/BlazorWasm.csproj new file mode 100644 index 00000000..e1542651 --- /dev/null +++ b/samples/BlazorWasm/BlazorWasm.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + + + diff --git a/samples/BlazorWasm/BlazorWasm.sln b/samples/BlazorWasm/BlazorWasm.sln new file mode 100644 index 00000000..a292bea5 --- /dev/null +++ b/samples/BlazorWasm/BlazorWasm.sln @@ -0,0 +1,41 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35027.167 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ProjectReferences", "ProjectReferences", "{73A8C054-9067-4474-B6D0-A0A924DF5DB6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigCatClient", "..\..\src\ConfigCatClient\ConfigCatClient.csproj", "{8F64DC62-D524-4310-A620-03D65ED829BC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorWasm", "BlazorWasm.csproj", "{B0E7F85F-048E-48A5-91BA-BB73FF3D7118}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Benchmark|Any CPU = Benchmark|Any CPU + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8F64DC62-D524-4310-A620-03D65ED829BC}.Benchmark|Any CPU.ActiveCfg = Benchmark|Any CPU + {8F64DC62-D524-4310-A620-03D65ED829BC}.Benchmark|Any CPU.Build.0 = Benchmark|Any CPU + {8F64DC62-D524-4310-A620-03D65ED829BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F64DC62-D524-4310-A620-03D65ED829BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F64DC62-D524-4310-A620-03D65ED829BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F64DC62-D524-4310-A620-03D65ED829BC}.Release|Any CPU.Build.0 = Release|Any CPU + {B0E7F85F-048E-48A5-91BA-BB73FF3D7118}.Benchmark|Any CPU.ActiveCfg = Release|Any CPU + {B0E7F85F-048E-48A5-91BA-BB73FF3D7118}.Benchmark|Any CPU.Build.0 = Release|Any CPU + {B0E7F85F-048E-48A5-91BA-BB73FF3D7118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B0E7F85F-048E-48A5-91BA-BB73FF3D7118}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0E7F85F-048E-48A5-91BA-BB73FF3D7118}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B0E7F85F-048E-48A5-91BA-BB73FF3D7118}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8F64DC62-D524-4310-A620-03D65ED829BC} = {73A8C054-9067-4474-B6D0-A0A924DF5DB6} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5F653DA7-4E85-40D3-AB27-E2599966E0AC} + EndGlobalSection +EndGlobal diff --git a/samples/BlazorWasm/Layout/MainLayout.razor b/samples/BlazorWasm/Layout/MainLayout.razor new file mode 100644 index 00000000..76eb7252 --- /dev/null +++ b/samples/BlazorWasm/Layout/MainLayout.razor @@ -0,0 +1,16 @@ +@inherits LayoutComponentBase +
+ + +
+
+ About +
+ +
+ @Body +
+
+
diff --git a/samples/BlazorWasm/Layout/MainLayout.razor.css b/samples/BlazorWasm/Layout/MainLayout.razor.css new file mode 100644 index 00000000..ecf25e5b --- /dev/null +++ b/samples/BlazorWasm/Layout/MainLayout.razor.css @@ -0,0 +1,77 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} diff --git a/samples/BlazorWasm/Layout/NavMenu.razor b/samples/BlazorWasm/Layout/NavMenu.razor new file mode 100644 index 00000000..45270106 --- /dev/null +++ b/samples/BlazorWasm/Layout/NavMenu.razor @@ -0,0 +1,29 @@ + + + + +@code { + private bool collapseNavMenu = true; + + private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; + + private void ToggleNavMenu() + { + collapseNavMenu = !collapseNavMenu; + } +} diff --git a/samples/BlazorWasm/Layout/NavMenu.razor.css b/samples/BlazorWasm/Layout/NavMenu.razor.css new file mode 100644 index 00000000..881d128a --- /dev/null +++ b/samples/BlazorWasm/Layout/NavMenu.razor.css @@ -0,0 +1,83 @@ +.navbar-toggler { + background-color: rgba(255, 255, 255, 0.1); +} + +.top-row { + height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep a { + color: #d7d7d7; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep a:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .collapse { + /* Never collapse the sidebar for wide screens */ + display: block; + } + + .nav-scrollable { + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/samples/BlazorWasm/Pages/Home.razor b/samples/BlazorWasm/Pages/Home.razor new file mode 100644 index 00000000..5b4b63d0 --- /dev/null +++ b/samples/BlazorWasm/Pages/Home.razor @@ -0,0 +1,23 @@ +@page "/" + +Home + +

Welcome to the ConfigCat Sample app for ASP.NET Core Blazor WebAssembly!

+ +
+ ConfigCat Logo +

❤️

+ Blazor Logo +
+
+
+

Simple feature flag.

+ +

Value returned from ConfigCat: @isAwesomeEnabled

+
+

Feature with Targeting

+

Set up to be enabled only for users with an e-mail address that contains "@@example.com"

+ + +

Value returned from ConfigCat: @isPOCEnabled

+
diff --git a/samples/BlazorWasm/Pages/Home.razor.cs b/samples/BlazorWasm/Pages/Home.razor.cs new file mode 100644 index 00000000..b246b099 --- /dev/null +++ b/samples/BlazorWasm/Pages/Home.razor.cs @@ -0,0 +1,27 @@ +using System.Diagnostics.CodeAnalysis; +using ConfigCat.Client; +using Microsoft.AspNetCore.Components; + +namespace BlazorWasm.Pages; + +public partial class Home : ComponentBase +{ + [Inject, NotNull] + private IConfigCatClient? ConfigCatClient { get; set; } + + private bool? isAwesomeEnabled; + private bool? isPOCEnabled; + private string userEmail = "configcat@example.com"; + + private async Task CheckAwesome() + { + this.isAwesomeEnabled = await ConfigCatClient.GetValueAsync("isAwesomeFeatureEnabled", false); + } + + private async Task CheckProofOfConcept() + { + var userObject = new User("#SOME-USER-ID#") { Email = this.userEmail }; + // Read more about the User Object: https://configcat.com/docs/advanced/user-object + this.isPOCEnabled = await ConfigCatClient.GetValueAsync("isPOCFeatureEnabled", false, userObject); + } +} diff --git a/samples/BlazorWasm/Pages/Home.razor.css b/samples/BlazorWasm/Pages/Home.razor.css new file mode 100644 index 00000000..f4126a8e --- /dev/null +++ b/samples/BlazorWasm/Pages/Home.razor.css @@ -0,0 +1,15 @@ +* { + text-align: center; +} + +.logos { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; +} + +.heart { + font-size: 2em; + margin-left: 0.5em; +} diff --git a/samples/BlazorWasm/Program.cs b/samples/BlazorWasm/Program.cs new file mode 100644 index 00000000..a43ffea1 --- /dev/null +++ b/samples/BlazorWasm/Program.cs @@ -0,0 +1,28 @@ +using BlazorWasm; +using ConfigCat.Client; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +builder.Logging.SetMinimumLevel(builder.HostEnvironment.IsDevelopment() + ? Microsoft.Extensions.Logging.LogLevel.Information + : Microsoft.Extensions.Logging.LogLevel.Warning); + +// Register ConfigCatClient as a singleton service so you can inject it in your components. +builder.Services.AddSingleton(sp => +{ + var logger = sp.GetRequiredService>(); + + return ConfigCatClient.Get("PKDVCLf-Hq-h-kCzMp-L7Q/HhOWfwVtZ0mb30i9wi17GQ", options => + { + options.PollingMode = PollingModes.AutoPoll(); + options.Logger = new WebApplication.Adapters.ConfigCatToMSLoggerAdapter(logger); + }); +}); + +await builder.Build().RunAsync(); diff --git a/samples/BlazorWasm/README.md b/samples/BlazorWasm/README.md new file mode 100644 index 00000000..36f1fe41 --- /dev/null +++ b/samples/BlazorWasm/README.md @@ -0,0 +1,26 @@ +# Sample ASP.NET Core Blazor WebAssembly app + +This is a simple [ASP.NET Core Blazor WebAssembly](https://learn.microsoft.com/en-us/aspnet/core/blazor) application to demonstrate how to use the ConfigCat SDK. + +1. Install the [.NET 8 SDK](https://dotnet.microsoft.com/download) +2. Run app + ```bash + dotnet run -- urls=http://localhost:5000 + ``` +3. Open http://localhost:5000 in browser + +## Ahead-of-time (AOT) compilation + +The sample app also demonstrates that the ConfigCat SDK can be used in [Blazor Wasm applications using AOT compilation](https://learn.microsoft.com/en-us/aspnet/core/blazor/webassembly-build-tools-and-aot). + +1. Make sure you have [the .NET WebAssembly build tools](https://learn.microsoft.com/en-us/aspnet/core/blazor/webassembly-build-tools-and-aot?view=aspnetcore-8.0#net-webassembly-build-tools) installed in your development environment. + ```bash + dotnet workload install wasm-tools + ``` +2. Execute the build script corresponding to your OS (`build-aot.cmd` on Windows, `build-aot.sh` on Linux). +3. Locate the web assets in the publish output directory (`bin/Release/net8.0/publish/wwwroot`). +4. Start a local web server in this directory to serve the files over HTTP. E.g. + ```bash + dotnet serve --port 5000 + ``` +5. Navigate to http://localhost:5000 in your browser. diff --git a/samples/BlazorWasm/_Imports.razor b/samples/BlazorWasm/_Imports.razor new file mode 100644 index 00000000..fb2c61e4 --- /dev/null +++ b/samples/BlazorWasm/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using BlazorWasm +@using BlazorWasm.Layout diff --git a/samples/BlazorWasm/build-aot.cmd b/samples/BlazorWasm/build-aot.cmd new file mode 100644 index 00000000..2b9612e0 --- /dev/null +++ b/samples/BlazorWasm/build-aot.cmd @@ -0,0 +1,3 @@ +@ECHO OFF + +dotnet publish -c Release -f net8.0 \ No newline at end of file diff --git a/samples/BlazorWasm/build-aot.sh b/samples/BlazorWasm/build-aot.sh new file mode 100644 index 00000000..ee34cb7d --- /dev/null +++ b/samples/BlazorWasm/build-aot.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +dotnet publish -c Release -f net8.0 \ No newline at end of file diff --git a/samples/BlazorWasm/libman.json b/samples/BlazorWasm/libman.json new file mode 100644 index 00000000..4c9a1a3d --- /dev/null +++ b/samples/BlazorWasm/libman.json @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "defaultProvider": "cdnjs", + "libraries": [ + { + "provider": "jsdelivr", + "library": "bootstrap@5.1.0", + "files": [ "dist/css/bootstrap.min.css", "dist/css/bootstrap.min.css.map" ], + "destination": "wwwroot/lib/bootstrap/" + } + ] +} diff --git a/samples/BlazorWasm/wwwroot/css/app.css b/samples/BlazorWasm/wwwroot/css/app.css new file mode 100644 index 00000000..54a8aa38 --- /dev/null +++ b/samples/BlazorWasm/wwwroot/css/app.css @@ -0,0 +1,103 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +a, .btn-link { + color: #0071c1; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid red; +} + +.validation-message { + color: red; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} diff --git a/samples/BlazorWasm/wwwroot/favicon.png b/samples/BlazorWasm/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~v0A9xRwxP|bki~~&uFk>U z#P+PQh zyZ;-jwXKqnKbb6)@RaxQz@vm={%t~VbaZrdbaZrdbaeEeXj>~BG?&`J0XrqR#sSlO zg~N5iUk*15JibvlR1f^^1czzNKWvoJtc!Sj*G37QXbZ8LeD{Fzxgdv#Q{x}ytfZ5q z+^k#NaEp>zX_8~aSaZ`O%B9C&YLHb(mNtgGD&Kezd5S@&C=n~Uy1NWHM`t07VQP^MopUXki{2^#ryd94>UJMYW|(#4qV`kb7eD)Q=~NN zaVIRi@|TJ!Rni8J=5DOutQ#bEyMVr8*;HU|)MEKmVC+IOiDi9y)vz=rdtAUHW$yjt zrj3B7v(>exU=IrzC<+?AE=2vI;%fafM}#ShGDZx=0Nus5QHKdyb9pw&4>4XCpa-o?P(Gnco1CGX|U> z$f+_tA3+V~<{MU^A%eP!8R*-sD9y<>Jc7A(;aC5hVbs;kX9&Sa$JMG!W_BLFQa*hM zri__C@0i0U1X#?)Y=)>JpvTnY6^s;fu#I}K9u>OldV}m!Ch`d1Vs@v9 zb}w(!TvOmSzmMBa9gYvD4xocL2r0ds6%Hs>Z& z#7#o9PGHDmfG%JQq`O5~dt|MAQN@2wyJw_@``7Giyy(yyk(m8U*kk5$X1^;3$a3}N^Lp6hE5!#8l z#~NYHmKAs6IAe&A;bvM8OochRmXN>`D`{N$%#dZCRxp4-dJ?*3P}}T`tYa3?zz5BA zTu7uE#GsDpZ$~j9q=Zq!LYjLbZPXFILZK4?S)C-zE1(dC2d<7nO4-nSCbV#9E|E1MM|V<9>i4h?WX*r*ul1 z5#k6;po8z=fdMiVVz*h+iaTlz#WOYmU^SX5#97H~B32s-#4wk<1NTN#g?LrYieCu> zF7pbOLR;q2D#Q`^t%QcY06*X-jM+ei7%ZuanUTH#9Y%FBi*Z#22({_}3^=BboIsbg zR0#jJ>9QR8SnmtSS6x($?$}6$x+q)697#m${Z@G6Ujf=6iO^S}7P`q8DkH!IHd4lB zDzwxt3BHsPAcXFFY^Fj}(073>NL_$A%v2sUW(CRutd%{G`5ow?L`XYSO*Qu?x+Gzv zBtR}Y6`XF4xX7)Z04D+fH;TMapdQFFameUuHL34NN)r@aF4RO%x&NApeWGtr#mG~M z6sEIZS;Uj1HB1*0hh=O@0q1=Ia@L>-tETu-3n(op+97E z#&~2xggrl(LA|giII;RwBlX2^Q`B{_t}gxNL;iB11gEPC>v` zb4SJ;;BFOB!{chn>?cCeGDKuqI0+!skyWTn*k!WiPNBf=8rn;@y%( znhq%8fj2eAe?`A5mP;TE&iLEmQ^xV%-kmC-8mWao&EUK_^=GW-Y3z ksi~={si~={skwfB0gq6itke#r1ONa407*qoM6N<$g11Kq@c;k- literal 0 HcmV?d00001 diff --git a/samples/BlazorWasm/wwwroot/index.html b/samples/BlazorWasm/wwwroot/index.html new file mode 100644 index 00000000..f1b4b941 --- /dev/null +++ b/samples/BlazorWasm/wwwroot/index.html @@ -0,0 +1,32 @@ + + + + + + + BlazorWasm + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + From a24492dfcbe58c22aa90a914d95d389488046066 Mon Sep 17 00:00:00 2001 From: Adam Simon Date: Sat, 27 Jul 2024 18:41:36 +0200 Subject: [PATCH 05/14] Refactor HttpConfigFetcher to allow custom config fetcher implementations --- .../ConfigCatClientTests.cs | 4 +- .../DataGovernanceTests.cs | 8 +- .../Helpers/ConfigLocation.Cdn.cs | 4 +- .../HttpConfigFetcherTests.cs | 14 +- src/ConfigCatClient/ConfigCatClient.cs | 7 +- .../ConfigService/ConfigServiceBase.cs | 2 + .../DefaultConfigFetcher.cs} | 129 +++++------------- .../ConfigService/FetchErrorException.cs | 47 +++++++ .../ConfigService/FetchRequest.cs | 42 ++++++ .../ConfigService/FetchResponse.cs | 49 +++++++ .../{ => ConfigService}/FetchResult.cs | 0 .../ConfigService/HttpClientConfigFetcher.cs | 126 +++++++++++++++++ .../ConfigService/IConfigCatConfigFetcher.cs | 22 +++ .../{ => ConfigService}/IConfigFetcher.cs | 0 .../Configuration/ConfigCatClientOptions.cs | 9 ++ 15 files changed, 353 insertions(+), 110 deletions(-) rename src/ConfigCatClient/{HttpConfigFetcher.cs => ConfigService/DefaultConfigFetcher.cs} (59%) create mode 100644 src/ConfigCatClient/ConfigService/FetchErrorException.cs create mode 100644 src/ConfigCatClient/ConfigService/FetchRequest.cs create mode 100644 src/ConfigCatClient/ConfigService/FetchResponse.cs rename src/ConfigCatClient/{ => ConfigService}/FetchResult.cs (100%) create mode 100644 src/ConfigCatClient/ConfigService/HttpClientConfigFetcher.cs create mode 100644 src/ConfigCatClient/ConfigService/IConfigCatConfigFetcher.cs rename src/ConfigCatClient/{ => ConfigService}/IConfigFetcher.cs (100%) diff --git a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs index 022cec61..c9ab8dad 100644 --- a/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigCatClientTests.cs @@ -1338,7 +1338,7 @@ public async Task EvaluationMethods_ShouldBeCancelable(string methodName) var loggerWrapper = this.loggerMock.Object.AsWrapper(); var fakeHandler = new FakeHttpClientHandler(HttpStatusCode.OK, "{ }", TimeSpan.FromMilliseconds(delayMs)); - var configFetcher = new HttpConfigFetcher(new Uri("http://example.com"), "1.0", loggerWrapper, fakeHandler, false, TimeSpan.FromMilliseconds(delayMs * 2)); + var configFetcher = new DefaultConfigFetcher(new Uri("http://example.com"), "1.0", loggerWrapper, new HttpClientConfigFetcher(fakeHandler), false, TimeSpan.FromMilliseconds(delayMs * 2)); var configCache = new InMemoryConfigCache(); var cacheParams = new CacheParameters(configCache, cacheKey: null!); var configService = new LazyLoadConfigService(configFetcher, cacheParams, loggerWrapper, TimeSpan.FromSeconds(1)); @@ -1529,7 +1529,7 @@ public async Task ForceRefreshAsync_ShouldBeCancelable() var loggerWrapper = this.loggerMock.Object.AsWrapper(); var fakeHandler = new FakeHttpClientHandler(HttpStatusCode.OK, "{ }", TimeSpan.FromMilliseconds(delayMs)); - var configFetcher = new HttpConfigFetcher(new Uri("http://example.com"), "1.0", loggerWrapper, fakeHandler, false, TimeSpan.FromMilliseconds(delayMs * 2)); + var configFetcher = new DefaultConfigFetcher(new Uri("http://example.com"), "1.0", loggerWrapper, new HttpClientConfigFetcher(fakeHandler), false, TimeSpan.FromMilliseconds(delayMs * 2)); var configCache = new InMemoryConfigCache(); var cacheParams = new CacheParameters(configCache, cacheKey: null!); var configService = new ManualPollConfigService(configFetcher, cacheParams, loggerWrapper); diff --git a/src/ConfigCat.Client.Tests/DataGovernanceTests.cs b/src/ConfigCat.Client.Tests/DataGovernanceTests.cs index 0cb42e00..a5e7e83f 100644 --- a/src/ConfigCat.Client.Tests/DataGovernanceTests.cs +++ b/src/ConfigCat.Client.Tests/DataGovernanceTests.cs @@ -56,11 +56,11 @@ public async Task WithDataGovernanceSetting_ShouldUseProperCdnUrl(DataGovernance }) .Verifiable(); - IConfigFetcher fetcher = new HttpConfigFetcher( + IConfigFetcher fetcher = new DefaultConfigFetcher( configuration.CreateUri(sdkKey), "DEMO", Mock.Of().AsWrapper(), - handlerMock.Object, + new HttpClientConfigFetcher(handlerMock.Object), configuration.IsCustomBaseUrl, TimeSpan.FromSeconds(30)); @@ -378,11 +378,11 @@ internal static async Task> Fetch( })) .Verifiable(); - IConfigFetcher fetcher = new HttpConfigFetcher( + IConfigFetcher fetcher = new DefaultConfigFetcher( fetchConfig.CreateUri(sdkKey), "DEMO", Mock.Of().AsWrapper(), - handlerMock.Object, + new HttpClientConfigFetcher(handlerMock.Object), fetchConfig.IsCustomBaseUrl, TimeSpan.FromSeconds(30)); diff --git a/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.Cdn.cs b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.Cdn.cs index a1091748..40d2aa29 100644 --- a/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.Cdn.cs +++ b/src/ConfigCat.Client.Tests/Helpers/ConfigLocation.Cdn.cs @@ -28,11 +28,11 @@ internal override Config FetchConfig() }; ConfigureBaseUrl(options); - using var configFetcher = new HttpConfigFetcher( + using var configFetcher = new DefaultConfigFetcher( options.CreateUri(SdkKey), ConfigCatClient.GetProductVersion(options.PollingMode), options.Logger!.AsWrapper(), - options.HttpClientHandler, + new HttpClientConfigFetcher(options.HttpClientHandler), options.IsCustomBaseUrl, options.HttpTimeout); diff --git a/src/ConfigCat.Client.Tests/HttpConfigFetcherTests.cs b/src/ConfigCat.Client.Tests/HttpConfigFetcherTests.cs index 984b7344..a223e0fc 100644 --- a/src/ConfigCat.Client.Tests/HttpConfigFetcherTests.cs +++ b/src/ConfigCat.Client.Tests/HttpConfigFetcherTests.cs @@ -18,7 +18,7 @@ public async Task HttpConfigFetcher_WithCustomHttpClientHandler_ShouldUsePassedH var myHandler = new FakeHttpClientHandler(); - var instance = new HttpConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), myHandler, false, + var instance = new DefaultConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), new HttpClientConfigFetcher(myHandler), false, TimeSpan.FromSeconds(30)); // Act @@ -37,7 +37,7 @@ public void HttpConfigFetcher_WithCustomHttpClientHandler_HandlersDisposeShouldN var myHandler = new FakeHttpClientHandler(); - var instance = new HttpConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), myHandler, false, + var instance = new DefaultConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), new HttpClientConfigFetcher(myHandler), false, TimeSpan.FromSeconds(30)); // Act @@ -56,7 +56,7 @@ public async Task HttpConfigFetcher_ResponseHttpCodeIsUnexpected_ShouldReturnPas var myHandler = new FakeHttpClientHandler(HttpStatusCode.Forbidden); - using var instance = new HttpConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), myHandler, false, + using var instance = new DefaultConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), new HttpClientConfigFetcher(myHandler), false, TimeSpan.FromSeconds(30)); var lastConfig = ConfigHelper.FromString("{}", timeStamp: ProjectConfig.GenerateTimeStamp(), httpETag: "\"ETAG\""); @@ -85,7 +85,7 @@ public async Task HttpConfigFetcher_ThrowAnException_ShouldReturnPassedConfig() var exception = new WebException(); var myHandler = new ExceptionThrowerHttpClientHandler(exception); - var instance = new HttpConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), myHandler, false, + var instance = new DefaultConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), new HttpClientConfigFetcher(myHandler), false, TimeSpan.FromSeconds(30)); var lastConfig = ConfigHelper.FromString("{}", timeStamp: ProjectConfig.GenerateTimeStamp(), httpETag: "\"ETAG\""); @@ -110,7 +110,7 @@ public async Task HttpConfigFetcher_OnlyOneFetchAsyncShouldBeInProgressAtATime_S var myHandler = new FakeHttpClientHandler(HttpStatusCode.OK, "{ }", TimeSpan.FromSeconds(1)); - var instance = new HttpConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), myHandler, false, TimeSpan.FromSeconds(30)); + var instance = new DefaultConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), new HttpClientConfigFetcher(myHandler), false, TimeSpan.FromSeconds(30)); var lastConfig = ConfigHelper.FromString("{}", timeStamp: ProjectConfig.GenerateTimeStamp(), httpETag: "\"ETAG\""); @@ -137,7 +137,7 @@ public async Task HttpConfigFetcher_OnlyOneFetchAsyncShouldBeInProgressAtATime_F var exception = new WebException(); var myHandler = new ExceptionThrowerHttpClientHandler(exception, TimeSpan.FromSeconds(1)); - var instance = new HttpConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), myHandler, false, TimeSpan.FromSeconds(30)); + var instance = new DefaultConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), new HttpClientConfigFetcher(myHandler), false, TimeSpan.FromSeconds(30)); var lastConfig = ConfigHelper.FromString("{}", timeStamp: ProjectConfig.GenerateTimeStamp(), httpETag: "\"ETAG\""); @@ -173,7 +173,7 @@ public async Task HttpConfigFetcher_FetchAsync_PendingOperationShouldBeJoined(bo var configETag = new EntityTagHeaderValue("\"123\""); var fakeHandler = new FakeHttpClientHandler(HttpStatusCode.OK, configContent, TimeSpan.FromMilliseconds(delayMs), configETag); - var configFetcher = new HttpConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), fakeHandler, false, TimeSpan.FromMilliseconds(delayMs * 2)); + var configFetcher = new DefaultConfigFetcher(new Uri("http://example.com"), "1.0", new CounterLogger().AsWrapper(), new HttpClientConfigFetcher(fakeHandler), false, TimeSpan.FromMilliseconds(delayMs * 2)); using var cts = new CancellationTokenSource(delayMs / 4); diff --git a/src/ConfigCatClient/ConfigCatClient.cs b/src/ConfigCatClient/ConfigCatClient.cs index 5aea65ee..dff33020 100644 --- a/src/ConfigCatClient/ConfigCatClient.cs +++ b/src/ConfigCatClient/ConfigCatClient.cs @@ -110,10 +110,11 @@ internal ConfigCatClient(string sdkKey, ConfigCatClientOptions options) this.configService = this.overrideBehaviour != OverrideBehaviour.LocalOnly ? DetermineConfigService(pollingMode, - new HttpConfigFetcher(options.CreateUri(sdkKey), + new DefaultConfigFetcher(options.CreateUri(sdkKey), GetProductVersion(pollingMode), logger, - options.HttpClientHandler, + options.ConfigFetcher + ?? ConfigCatClientOptions.CreateDefaultConfigFetcher(options.HttpClientHandler), options.IsCustomBaseUrl, options.HttpTimeout), cacheParameters, @@ -753,7 +754,7 @@ async Task GetRemoteConfigAsync(CancellationToken canc } } - private static IConfigService DetermineConfigService(PollingMode pollingMode, HttpConfigFetcher fetcher, CacheParameters cacheParameters, LoggerWrapper logger, bool isOffline, SafeHooksWrapper hooks) + private static IConfigService DetermineConfigService(PollingMode pollingMode, IConfigFetcher fetcher, CacheParameters cacheParameters, LoggerWrapper logger, bool isOffline, SafeHooksWrapper hooks) { return pollingMode switch { diff --git a/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs b/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs index 2746cbad..7423ef46 100644 --- a/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs +++ b/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs @@ -161,6 +161,8 @@ protected async Task RefreshConfigCoreAsync(ProjectConfig protected virtual void OnConfigFetched(in FetchResult fetchResult, bool isInitiatedByUser) { + this.Logger.Debug("config fetched"); + this.Hooks.RaiseConfigFetched(RefreshResult.From(fetchResult), isInitiatedByUser); } diff --git a/src/ConfigCatClient/HttpConfigFetcher.cs b/src/ConfigCatClient/ConfigService/DefaultConfigFetcher.cs similarity index 59% rename from src/ConfigCatClient/HttpConfigFetcher.cs rename to src/ConfigCatClient/ConfigService/DefaultConfigFetcher.cs index ede9daa8..744ff97a 100644 --- a/src/ConfigCatClient/HttpConfigFetcher.cs +++ b/src/ConfigCatClient/ConfigService/DefaultConfigFetcher.cs @@ -1,37 +1,36 @@ using System; +using System.Collections.Generic; using System.Net; -using System.Net.Http; using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; namespace ConfigCat.Client; -internal sealed class HttpConfigFetcher : IConfigFetcher, IDisposable +internal sealed class DefaultConfigFetcher : IConfigFetcher, IDisposable { private readonly object syncObj = new(); - private readonly string productVersion; + private readonly KeyValuePair sdkInfoHeader; private readonly LoggerWrapper logger; - - private readonly HttpClientHandler? httpClientHandler; + private readonly IConfigCatConfigFetcher configFetcher; private readonly bool isCustomUri; private readonly TimeSpan timeout; private readonly CancellationTokenSource cancellationTokenSource = new(); - private HttpClient httpClient; internal Task? pendingFetch; private Uri requestUri; - public HttpConfigFetcher(Uri requestUri, string productVersion, LoggerWrapper logger, - HttpClientHandler? httpClientHandler, bool isCustomUri, TimeSpan timeout) + public DefaultConfigFetcher(Uri requestUri, string productVersion, LoggerWrapper logger, + IConfigCatConfigFetcher configFetcher, bool isCustomUri, TimeSpan timeout) { this.requestUri = requestUri; - this.productVersion = productVersion; + this.sdkInfoHeader = new KeyValuePair( + "X-ConfigCat-UserAgent", + new ProductInfoHeaderValue("ConfigCat-Dotnet", productVersion).ToString()); this.logger = logger; - this.httpClientHandler = httpClientHandler; + this.configFetcher = configFetcher; this.isCustomUri = isCustomUri; this.timeout = timeout; - this.httpClient = CreateHttpClient(); } public FetchResult Fetch(ProjectConfig lastConfig) @@ -78,25 +77,25 @@ private async ValueTask FetchInternalAsync(ProjectConfig lastConfig Exception errorException; try { - var responseWithBody = await FetchRequestAsync(lastConfig.HttpETag, this.requestUri).ConfigureAwait(false); + var deserializedResponse = await FetchRequestAsync(lastConfig.HttpETag, this.requestUri).ConfigureAwait(false); - var response = responseWithBody.Response; + var response = deserializedResponse.Response; switch (response.StatusCode) { case HttpStatusCode.OK: - var config = responseWithBody.Config; + var config = deserializedResponse.Config; if (config is null) { - var exception = responseWithBody.Exception; + var exception = deserializedResponse.Exception; logMessage = this.logger.FetchReceived200WithInvalidBody(exception); return FetchResult.Failure(lastConfig, RefreshErrorCode.InvalidHttpResponseContent, logMessage.InvariantFormattedMessage, exception); } return FetchResult.Success(new ProjectConfig ( - httpETag: response.Headers.ETag?.Tag, - configJson: responseWithBody.ResponseBody, + httpETag: response.ETag, + configJson: response.Body, config: config, timeStamp: ProjectConfig.GenerateTimeStamp() )); @@ -119,27 +118,21 @@ private async ValueTask FetchInternalAsync(ProjectConfig lastConfig default: logMessage = this.logger.FetchFailedDueToUnexpectedHttpResponse((int)response.StatusCode, response.ReasonPhrase); - - ReInitializeHttpClient(); return FetchResult.Failure(lastConfig, RefreshErrorCode.UnexpectedHttpResponse, logMessage.InvariantFormattedMessage); } } - catch (OperationCanceledException) when (this.cancellationTokenSource.IsCancellationRequested) + catch (OperationCanceledException) { - // NOTE: Unfortunately, we can't check the CancellationToken property of the exception in the when condition above because - // it seems that HttpClient.SendAsync combines our token with another one under the hood (at least, in runtimes earlier than .NET 6), - // so we'd get a Linked2CancellationTokenSource here instead of our token which we pass to the HttpClient.SendAsync method... - /* do nothing on dispose cancel */ return FetchResult.NotModified(lastConfig); } - catch (OperationCanceledException ex) + catch (FetchErrorException.Timeout_ ex) { - logMessage = this.logger.FetchFailedDueToRequestTimeout(this.timeout, ex); + logMessage = this.logger.FetchFailedDueToRequestTimeout(ex.Timeout, ex); errorCode = RefreshErrorCode.HttpRequestTimeout; errorException = ex; } - catch (HttpRequestException ex) when (ex.InnerException is WebException { Status: WebExceptionStatus.SecureChannelFailure }) + catch (FetchErrorException.Failure_ ex) when (ex.Status == WebExceptionStatus.SecureChannelFailure) { logMessage = this.logger.EstablishingSecureConnectionFailed(ex); errorCode = RefreshErrorCode.HttpRequestFailure; @@ -152,34 +145,22 @@ private async ValueTask FetchInternalAsync(ProjectConfig lastConfig errorException = ex; } - ReInitializeHttpClient(); return FetchResult.Failure(lastConfig, errorCode, logMessage.InvariantFormattedMessage, errorException); } - private async ValueTask FetchRequestAsync(string? httpETag, Uri requestUri, sbyte maxExecutionCount = 3) + private async ValueTask FetchRequestAsync(string? httpETag, Uri requestUri, sbyte maxExecutionCount = 3) { for (; ; maxExecutionCount--) { - var request = new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = requestUri }; + var request = new FetchRequest(this.requestUri, httpETag, this.sdkInfoHeader, this.timeout); - if (httpETag is not null) - { - request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(httpETag)); - } - - var response = await this.httpClient.SendAsync(request, this.cancellationTokenSource.Token).ConfigureAwait(false); + var response = await this.configFetcher.FetchAsync(request, this.cancellationTokenSource.Token).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.OK) { -#if NET5_0_OR_GREATER - var responseBody = await response.Content.ReadAsStringAsync(this.cancellationTokenSource.Token).ConfigureAwait(false); -#else - var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); -#endif - Config config; - try { config = Config.Deserialize(responseBody.AsMemory()); } - catch (Exception ex) { return new ResponseWithBody(response, responseBody, ex); } + try { config = Config.Deserialize(response.Body.AsMemory()); } + catch (Exception ex) { return new DeserializedResponse(response, ex); } if (config.Preferences is not null) { @@ -187,21 +168,21 @@ private async ValueTask FetchRequestAsync(string? httpETag, Ur if (newBaseUrl is null || requestUri.Host == new Uri(newBaseUrl).Host) { - return new ResponseWithBody(response, responseBody, config); + return new DeserializedResponse(response, config); } RedirectMode redirect = config.Preferences.RedirectMode; if (this.isCustomUri && redirect != RedirectMode.Force) { - return new ResponseWithBody(response, responseBody, config); + return new DeserializedResponse(response, config); } UpdateRequestUri(new Uri(newBaseUrl)); if (redirect == RedirectMode.No) { - return new ResponseWithBody(response, responseBody, config); + return new DeserializedResponse(response, config); } if (redirect == RedirectMode.Should) @@ -212,17 +193,17 @@ private async ValueTask FetchRequestAsync(string? httpETag, Ur if (maxExecutionCount <= 1) { this.logger.FetchFailedDueToRedirectLoop(); - return new ResponseWithBody(response, responseBody, config); + return new DeserializedResponse(response, config); } - requestUri = ReplaceUri(request.RequestUri, new Uri(newBaseUrl)); + requestUri = ReplaceUri(request.Uri, new Uri(newBaseUrl)); continue; } - return new ResponseWithBody(response, responseBody, config); + return new DeserializedResponse(response, config); } - return new ResponseWithBody(response, null, (Exception?)null); + return new DeserializedResponse(response, (Exception?)null); } } @@ -239,65 +220,29 @@ private static Uri ReplaceUri(Uri oldUri, Uri newUri) return new Uri(newUri, oldUri.AbsolutePath); } - private void ReInitializeHttpClient() - { - lock (this.syncObj) - { - this.httpClient = CreateHttpClient(); - } - } - - private HttpClient CreateHttpClient() - { - HttpClient client; - - if (this.httpClientHandler is null) - { - var handler = new HttpClientHandler(); - if (handler.SupportsAutomaticDecompression) - { - handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; - } - client = new HttpClient(handler); - } - else - { - client = new HttpClient(this.httpClientHandler, false); - } - - client.Timeout = this.timeout; - client.DefaultRequestHeaders.Add("X-ConfigCat-UserAgent", - new ProductInfoHeaderValue("ConfigCat-Dotnet", this.productVersion).ToString()); - - return client; - } - public void Dispose() { this.cancellationTokenSource.Cancel(); - this.httpClient?.Dispose(); + this.configFetcher.Dispose(); } - private readonly struct ResponseWithBody + private readonly struct DeserializedResponse { private readonly object? configOrException; - public ResponseWithBody(HttpResponseMessage response, string responseBody, Config config) + public DeserializedResponse(in FetchResponse response, Config config) { Response = response; - ResponseBody = responseBody; this.configOrException = config; } - public ResponseWithBody(HttpResponseMessage response, string? responseBody, Exception? exception) + public DeserializedResponse(in FetchResponse response, Exception? exception) { Response = response; - ResponseBody = responseBody; this.configOrException = exception; } - public HttpResponseMessage Response { get; } - public string? ResponseBody { get; } + public FetchResponse Response { get; } public Config? Config => this.configOrException as Config; public Exception? Exception => this.configOrException as Exception; } diff --git a/src/ConfigCatClient/ConfigService/FetchErrorException.cs b/src/ConfigCatClient/ConfigService/FetchErrorException.cs new file mode 100644 index 00000000..fe9d0612 --- /dev/null +++ b/src/ConfigCatClient/ConfigService/FetchErrorException.cs @@ -0,0 +1,47 @@ +using System; +using System.Net; + +namespace ConfigCat.Client; + +/// +/// Represents errors that can occur during a ConfigCat config fetch operation. +/// +public abstract class FetchErrorException : Exception +{ + /// + /// Creates an instance of the struct which indicates that the operation timed out. + /// + public static FetchErrorException Timeout(TimeSpan timeout, Exception? innerException = null) + => new Timeout_($"Request timed out. Timeout value: {timeout}", timeout, innerException); + + /// + /// Creates an instance of the struct which indicates that the operation failed due to a network or protocol error. + /// + public static FetchErrorException Failure(WebExceptionStatus? status, Exception? innerException = null) + => new Failure_("Request failed due to a network or protocol error.", status, innerException); + + private FetchErrorException(string message, Exception? innerException) + : base(message, innerException) { } + + internal sealed class Timeout_ : FetchErrorException + { + public Timeout_(string message, TimeSpan timeout, Exception? innerException) + : base(message, innerException) + { + Timeout = timeout; + } + + public new TimeSpan Timeout { get; } + } + + internal sealed class Failure_ : FetchErrorException + { + public Failure_(string message, WebExceptionStatus? status, Exception? innerException) + : base(message, innerException) + { + Status = status; + } + + public WebExceptionStatus? Status { get; } + } +} diff --git a/src/ConfigCatClient/ConfigService/FetchRequest.cs b/src/ConfigCatClient/ConfigService/FetchRequest.cs new file mode 100644 index 00000000..2590cb99 --- /dev/null +++ b/src/ConfigCatClient/ConfigService/FetchRequest.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using ConfigCat.Client.Configuration; + +namespace ConfigCat.Client; + +/// +/// The request parameters for a ConfigCat config fetch operation. +/// +public readonly struct FetchRequest +{ + /// + /// Initializes a new instance of the struct. + /// + public FetchRequest(Uri uri, string? lastETag, KeyValuePair sdkInfoHeader, TimeSpan timeout) + { + Uri = uri ?? throw new ArgumentNullException(nameof(uri)); + LastETag = lastETag; + SdkInfoHeader = sdkInfoHeader; + Timeout = timeout; + } + + /// + /// The URI of the config. + /// + public Uri Uri { get; } + + /// + /// The value of the ETag HTTP response header received during the last successful request (if any). + /// + public string? LastETag { get; } + + /// + /// The name and value of the HTTP request header containing information about the SDK. Should be included in every request. + /// + public KeyValuePair SdkInfoHeader { get; } + + /// + /// The request timeout to apply, configured via . + /// + public TimeSpan Timeout { get; } +} diff --git a/src/ConfigCatClient/ConfigService/FetchResponse.cs b/src/ConfigCatClient/ConfigService/FetchResponse.cs new file mode 100644 index 00000000..d1907a1b --- /dev/null +++ b/src/ConfigCatClient/ConfigService/FetchResponse.cs @@ -0,0 +1,49 @@ +using System.Net; + +namespace ConfigCat.Client; + +/// +/// The response data of a ConfigCat config fetch operation. +/// +public readonly struct FetchResponse +{ + /// + /// Initializes a new instance of the struct. + /// + public FetchResponse(HttpStatusCode statusCode, string? reasonPhrase, string? eTag, string? body) + { + StatusCode = statusCode; + ReasonPhrase = reasonPhrase; + ETag = eTag; + Body = body; + } + + /// + /// The HTTP status code. + /// + public HttpStatusCode StatusCode { get; } + + /// + /// The HTTP reason phrase. + /// + public string? ReasonPhrase { get; } + + /// + /// The value of the ETag HTTP response header. + /// + public string? ETag { get; } + + /// + /// The response body. + /// + public string? Body { get; } + + /// + /// Indicates whether the response is expected or not. + /// + public bool IsExpected => StatusCode is + HttpStatusCode.OK + or HttpStatusCode.NotModified + or HttpStatusCode.Forbidden + or HttpStatusCode.NotFound; +} diff --git a/src/ConfigCatClient/FetchResult.cs b/src/ConfigCatClient/ConfigService/FetchResult.cs similarity index 100% rename from src/ConfigCatClient/FetchResult.cs rename to src/ConfigCatClient/ConfigService/FetchResult.cs diff --git a/src/ConfigCatClient/ConfigService/HttpClientConfigFetcher.cs b/src/ConfigCatClient/ConfigService/HttpClientConfigFetcher.cs new file mode 100644 index 00000000..764d7114 --- /dev/null +++ b/src/ConfigCatClient/ConfigService/HttpClientConfigFetcher.cs @@ -0,0 +1,126 @@ +using System; +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace ConfigCat.Client; + +internal class HttpClientConfigFetcher : IConfigCatConfigFetcher +{ + private readonly HttpClientHandler? httpClientHandler; + private volatile HttpClient? httpClient; + + public HttpClientConfigFetcher(HttpClientHandler? httpClientHandler) + { + this.httpClientHandler = httpClientHandler; + } + + public void Dispose() + { + this.httpClient?.Dispose(); + } + + private HttpClient CreateClient(TimeSpan timeout) + { + HttpClient httpClient; + + if (this.httpClientHandler is null) + { + var handler = new HttpClientHandler(); + if (handler.SupportsAutomaticDecompression) + { + handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; + } + httpClient = new HttpClient(handler); + } + else + { + httpClient = new HttpClient(this.httpClientHandler, disposeHandler: false); + } + + httpClient.Timeout = timeout; + + return httpClient; + } + + public async Task FetchAsync(FetchRequest request, CancellationToken cancellationToken) + { + var httpClient = this.httpClient; + if (httpClient is null) + { + httpClient = CreateClient(request.Timeout); + + if (Interlocked.CompareExchange(ref this.httpClient, httpClient, comparand: null) is { } currentHttpClient) + { + httpClient = currentHttpClient; + } + } + else + { + // NOTE: Request timeout should not change during the lifetime of the client instance. + Debug.Assert(httpClient.Timeout == request.Timeout, "Request timeout changed."); + } + + var httpRequest = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = request.Uri, + }; + + httpRequest.Headers.Add(request.SdkInfoHeader.Key, request.SdkInfoHeader.Value); + + if (request.LastETag is not null) + { + httpRequest.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(request.LastETag)); + } + + try + { + var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + + if (httpResponse.StatusCode == HttpStatusCode.OK) + { +#if NET5_0_OR_GREATER + var httpResponseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + var httpResponseBody = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + + return new FetchResponse(httpResponse.StatusCode, httpResponse.ReasonPhrase, httpResponse.Headers.ETag?.Tag, httpResponseBody); + } + else + { + var response = new FetchResponse(httpResponse.StatusCode, httpResponse.ReasonPhrase, eTag: null, body: null); + if (!response.IsExpected) + { + this.httpClient = null; + } + return response; + } + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + // NOTE: Unfortunately, we can't check the CancellationToken property of the exception in the when condition above because + // it seems that HttpClient.SendAsync combines our token with another one under the hood (at least, in runtimes earlier than .NET 6), + // so we'd get a Linked2CancellationTokenSource here instead of our token which we pass to the HttpClient.SendAsync method... + + this.httpClient = null; + throw FetchErrorException.Timeout(httpClient.Timeout, ex); + } + catch (HttpRequestException ex) + { + // Let the HttpClient to be recreated so it can pick up potentially changed DNS entries + // (see also https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines#dns-behavior). + this.httpClient = null; + throw FetchErrorException.Failure((ex.InnerException as WebException)?.Status, ex); + } + catch + { + this.httpClient = null; + throw; + } + } +} diff --git a/src/ConfigCatClient/ConfigService/IConfigCatConfigFetcher.cs b/src/ConfigCatClient/ConfigService/IConfigCatConfigFetcher.cs new file mode 100644 index 00000000..cd7ba1c8 --- /dev/null +++ b/src/ConfigCatClient/ConfigService/IConfigCatConfigFetcher.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ConfigCat.Client; + +/// +/// Defines the interface used by the ConfigCat SDK to perform ConfigCat config fetch operations. +/// +public interface IConfigCatConfigFetcher : IDisposable +{ + /// + /// Fetches the JSON content of the requested config asynchronously. + /// + /// The fetch request. + /// A to observe while waiting for the task to complete. + /// If the token is canceled, the request should be aborted. + /// A task that represents the asynchronous operation. The task result contains the fetch response. + /// The fetch operation failed. + /// is canceled during the execution of the task. + Task FetchAsync(FetchRequest request, CancellationToken cancellationToken); +} diff --git a/src/ConfigCatClient/IConfigFetcher.cs b/src/ConfigCatClient/ConfigService/IConfigFetcher.cs similarity index 100% rename from src/ConfigCatClient/IConfigFetcher.cs rename to src/ConfigCatClient/ConfigService/IConfigFetcher.cs diff --git a/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs b/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs index 6911dc10..03f30a31 100644 --- a/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs +++ b/src/ConfigCatClient/Configuration/ConfigCatClientOptions.cs @@ -26,6 +26,15 @@ public class ConfigCatClientOptions : IProvidesHooks internal static IConfigCatLogger CreateDefaultLogger() => new ConsoleLogger(LogLevel.Warning); + /// + /// The config fetcher implementation to use for perform ConfigCat config fetch operations. + /// If not set, will be used by default, which is based on .
+ /// If you want to use custom a config fetcher, you can provide an implementation of . + ///
+ public IConfigCatConfigFetcher? ConfigFetcher { get; set; } + + internal static IConfigCatConfigFetcher CreateDefaultConfigFetcher(HttpClientHandler? httpClientHandler) => new HttpClientConfigFetcher(httpClientHandler); + /// /// The cache implementation to use for storing and retrieving downloaded config data. /// If not set, will be used by default.
From 213a9cc341f2bd5a6fcd91f3fa0e8a9ab9cb7e5f Mon Sep 17 00:00:00 2001 From: Adam Simon Date: Sat, 27 Jul 2024 18:45:35 +0200 Subject: [PATCH 06/14] Add support for Unity WebGL --- .../Assets/Scripts/ButtonClickHandler.cs | 51 ++++ .../Assets/Scripts/SingletonServices.cs | 247 ++++++++++++++++++ samples/UnityWebGL/README.md | 16 ++ .../Cache/ExternalConfigCache.cs | 5 +- src/ConfigCatClient/ConfigCatClient.cs | 54 ++-- src/ConfigCatClient/ConfigCatClient.csproj | 4 - .../ConfigService/AutoPollConfigService.cs | 31 ++- .../ConfigService/ConfigServiceBase.cs | 11 +- .../ConfigService/DefaultConfigFetcher.cs | 9 +- .../ConfigService/HttpClientConfigFetcher.cs | 7 +- .../ConfigService/LazyLoadConfigService.cs | 7 +- .../ConfigService/ManualPollConfigService.cs | 3 +- .../PlatformCompatibilityOptions.cs | 62 +++++ .../Extensions/TaskExtensions.cs | 8 +- .../Override/LocalFileDataSource.cs | 13 +- src/ConfigCatClient/Shims/DefaultTaskShim.cs | 14 + src/ConfigCatClient/Shims/TaskShim.cs | 42 +++ 17 files changed, 525 insertions(+), 59 deletions(-) create mode 100644 samples/UnityWebGL/Assets/Scripts/ButtonClickHandler.cs create mode 100644 samples/UnityWebGL/Assets/Scripts/SingletonServices.cs create mode 100644 samples/UnityWebGL/README.md create mode 100644 src/ConfigCatClient/Configuration/PlatformCompatibilityOptions.cs create mode 100644 src/ConfigCatClient/Shims/DefaultTaskShim.cs create mode 100644 src/ConfigCatClient/Shims/TaskShim.cs diff --git a/samples/UnityWebGL/Assets/Scripts/ButtonClickHandler.cs b/samples/UnityWebGL/Assets/Scripts/ButtonClickHandler.cs new file mode 100644 index 00000000..59bbfab4 --- /dev/null +++ b/samples/UnityWebGL/Assets/Scripts/ButtonClickHandler.cs @@ -0,0 +1,51 @@ +using System; +using ConfigCat.Client; +using TMPro; +using UnityEngine; + +public class ButtonClickHandler : MonoBehaviour +{ + public TMP_Text Text; + + // Start is called before the first frame update + void Start() + { + + } + + // Update is called once per frame + void Update() + { + + } + + public async void HandleClick() + { + try + { + var configCatClient = SingletonServices.Instance.ConfigCatClient; + + // Creating a user object to identify the user (optional) + var user = new User("") + { + Country = "US", + Email = "configcat@example.com", + Custom = + { + { "SubscriptionType", "Pro"}, + { "Role", "Admin"}, + { "version", "1.0.0" } + } + }; + + var value = await configCatClient.GetValueAsync("isPOCFeatureEnabled", false, user); + + Text.SetText($"Value returned from ConfigCat: {value}"); + Text.gameObject.SetActive(true); + } + catch (Exception ex) + { + Debug.LogException(ex); + } + } +} diff --git a/samples/UnityWebGL/Assets/Scripts/SingletonServices.cs b/samples/UnityWebGL/Assets/Scripts/SingletonServices.cs new file mode 100644 index 00000000..f55142c0 --- /dev/null +++ b/samples/UnityWebGL/Assets/Scripts/SingletonServices.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections; +using System.Globalization; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using ConfigCat.Client; +using ConfigCat.Client.Shims; +using UnityEngine; +using UnityEngine.Networking; + +// Assign this script to the first scene, then you can access the provided services via SingletonServices.Instance in your scripts. +// See also: +// * https://gamedev.stackexchange.com/questions/116009/in-unity-how-do-i-correctly-implement-the-singleton-pattern +// * https://gamedevbeginner.com/singletons-in-unity-the-right-way/ +public class SingletonServices : MonoBehaviour +{ + private static SingletonServices instance; + public static SingletonServices Instance => instance; + + [field: NonSerialized] + public IConfigCatClient ConfigCatClient { get; private set; } + + private void Awake() + { + if (Interlocked.CompareExchange(ref instance, this, null) is not null) + { + // If another instance has been created already, get rid of this one. + Destroy(this); + return; + } + + ConfigCat.Client.ConfigCatClient.PlatformCompatibilityOptions.EnableUnityWebGLCompatibility(new UnityTaskShim(this), () => new UnityWebRequestConfigFetcher(this)); + + ConfigCatClient = ConfigCat.Client.ConfigCatClient.Get("PKDVCLf-Hq-h-kCzMp-L7Q/HhOWfwVtZ0mb30i9wi17GQ", options => + { + options.PollingMode = PollingModes.AutoPoll(pollInterval: TimeSpan.FromSeconds(10)); + options.Logger = new ConfigCatToUnityDebugLogAdapter(LogLevel.Debug); + }); + + DontDestroyOnLoad(this.gameObject); + } + + // Start is called before the first frame update + void Start() + { + + } + + // Update is called once per frame + void Update() + { + } + + private void OnDestroy() + { + this.ConfigCatClient?.Dispose(); + this.ConfigCatClient = null; + } + + private sealed class UnityTaskShim : TaskShim + { + private readonly SingletonServices singletonServices; + + public UnityTaskShim(SingletonServices singletonServices) + { + this.singletonServices = singletonServices; + } + + public override async Task Run(Func> function, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + return await function(); + } + + public override async Task Delay(TimeSpan delay, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (delay == TimeSpan.Zero) + { + return; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + IEnumerator wait = null; + var ctr = cancellationToken.CanBeCanceled + ? cancellationToken.Register(() => + { + try + { + if (wait is not null) + { + this.singletonServices.StopCoroutine(wait); + } + } + catch { /* there's nothing to do if StopCoroutine fails */ } + finally { tcs.TrySetCanceled(cancellationToken); } + }, useSynchronizationContext: true) + : default; + + if (delay != Timeout.InfiniteTimeSpan) + { + wait = Wait(delay, tcs, ctr); + + static IEnumerator Wait(TimeSpan delay, TaskCompletionSource tcs, CancellationTokenRegistration ctr) + { + yield return new WaitForSecondsRealtime((float)delay.TotalSeconds); + + try { ctr.Dispose(); } + catch { /* there's nothing to do if Dispose fails */ } + finally { tcs.TrySetResult(null); } + } + + this.singletonServices.StartCoroutine(wait); + } + + await tcs.Task; + } + } + + private sealed class UnityWebRequestConfigFetcher : IConfigCatConfigFetcher + { + private readonly SingletonServices singletonServices; + + public UnityWebRequestConfigFetcher(SingletonServices singletonServices) + { + this.singletonServices = singletonServices; + } + + public async Task FetchAsync(FetchRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var uri = request.Uri; + + // NOTE: It's intentional that we don't specify the If-None-Match header. + // The browser should automatically handle it, adding it manually would cause an unnecessary CORS OPTIONS request. + // For the case where the browser doesn't handle it, we also send the etag in the ccetag query parameter. + + if (request.LastETag is not null) + { + var query = HttpUtility.ParseQueryString(uri.Query); + query["ccetag"] = request.LastETag; + var uriBuilder = new UriBuilder(uri); + uriBuilder.Query = query.ToString(); + uri = uriBuilder.Uri; + } + + Debug.Log($"Fetching config at {uri}..."); + + using var webRequest = UnityWebRequest.Get(uri); + + webRequest.SetRequestHeader(request.SdkInfoHeader.Key, request.SdkInfoHeader.Value); + + webRequest.timeout = (int)request.Timeout.TotalSeconds; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var ctr = cancellationToken.CanBeCanceled + ? cancellationToken.Register(() => + { + try { webRequest.Abort(); } + catch { /* there's nothing to do if Abort fails */ } + finally { tcs.TrySetCanceled(cancellationToken); } + }, useSynchronizationContext: true) + : default; + + webRequest.SendWebRequest().completed += (_) => + { + try { ctr.Dispose(); } + catch { /* there's nothing to do if Dispose fails */ } + finally { tcs.TrySetResult(null); } + }; + + await tcs.Task; + + if (webRequest.result == UnityWebRequest.Result.Success) + { + var statusCode = (HttpStatusCode)webRequest.responseCode; + Debug.Log($"Fetching config finished with status code {statusCode}."); + if (statusCode == HttpStatusCode.OK) + { + var eTag = webRequest.GetResponseHeader("etag"); + var text = webRequest.downloadHandler.text; + return new FetchResponse(statusCode, reasonPhrase: null, eTag is { Length: > 0 } ? eTag : null, webRequest.downloadHandler.text); + } + else + { + return new FetchResponse(statusCode, reasonPhrase: null, null, null); + } + } + else if (webRequest.result == UnityWebRequest.Result.ConnectionError && webRequest.error == "Request timeout") + { + Debug.Log($"Fetching config timed out."); + throw FetchErrorException.Timeout(TimeSpan.FromSeconds(webRequest.timeout)); + } + else + { + Debug.Log($"Fetching config failed due to {webRequest.result}: {webRequest.error}"); + throw FetchErrorException.Failure(null, new Exception($"Web request failed due to {webRequest.result}: {webRequest.error}")); + } + } + + public void Dispose() { } + } + + private sealed class ConfigCatToUnityDebugLogAdapter : IConfigCatLogger + { + private readonly LogLevel logLevel; + + public ConfigCatToUnityDebugLogAdapter(LogLevel logLevel) + { + this.logLevel = logLevel; + } + + public LogLevel LogLevel + { + get => this.logLevel; + set { throw new NotSupportedException(); } + } + + public void Log(LogLevel level, LogEventId eventId, ref FormattableLogMessage message, Exception exception = null) + { + var eventIdString = eventId.Id.ToString(CultureInfo.InvariantCulture); + var exceptionString = exception is null ? string.Empty : Environment.NewLine + exception; + var logMessage = $"ConfigCat[{eventIdString}] {message.InvariantFormattedMessage}{exceptionString}"; + switch (level) + { + case LogLevel.Error: + Debug.LogError(logMessage); + break; + case LogLevel.Warning: + Debug.LogWarning(logMessage); + break; + case LogLevel.Info: + Debug.Log(logMessage); + break; + case LogLevel.Debug: + Debug.Log(logMessage); + break; + } + } + } +} diff --git a/samples/UnityWebGL/README.md b/samples/UnityWebGL/README.md new file mode 100644 index 00000000..32e7f714 --- /dev/null +++ b/samples/UnityWebGL/README.md @@ -0,0 +1,16 @@ +# Sample script files for Unity WebGL + +This folder contains a few C# script files that show how you can integrate and use the ConfigCat SDK in your Unity WebGL application or game. + +Since NuGet packages cannot be referenced in Unity projects directly, the SDK's assembly file (`ConfigCat.Client.dll`) and its dependencies must be added manually. You will need to include the *netstandard2.0* builds of the following assemblies: +* [ConfigCat.Client v9.3.0+](https://www.nuget.org/packages/ConfigCat.Client) +* [Microsoft.Bcl.AsyncInterfaces v6.0.0](https://www.nuget.org/packages/Microsoft.Bcl.AsyncInterfaces/6.0.0) +* [System.Buffers v4.5.1](https://www.nuget.org/packages/System.Buffers/4.5.1) +* [System.Memory v4.5.4](https://www.nuget.org/packages/System.Memory/4.5.4) +* [System.Numerics.Vectors v4.5.0](https://www.nuget.org/packages/System.Numerics.Vectors/4.5.0) +* [System.Runtime.CompilerServices.Unsafe v6.0.0](https://www.nuget.org/packages/System.Runtime.CompilerServices.Unsafe/6.0.0) +* [System.Text.Encodings.Web v6.0.0](https://www.nuget.org/packages/System.Text.Encodings.Web/6.0.0) +* [System.Text.Json v6.0.0](https://www.nuget.org/packages/System.Text.Json/6.0.0) +* [System.Threading.Tasks.Extensions v4.5.4](https://www.nuget.org/packages/System.Threading.Tasks.Extensions/4.5.4) + +Tested on Unity 2021.3 LTS. \ No newline at end of file diff --git a/src/ConfigCatClient/Cache/ExternalConfigCache.cs b/src/ConfigCatClient/Cache/ExternalConfigCache.cs index 1b8dd3bc..e3105286 100644 --- a/src/ConfigCatClient/Cache/ExternalConfigCache.cs +++ b/src/ConfigCatClient/Cache/ExternalConfigCache.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using ConfigCat.Client.Shims; namespace ConfigCat.Client.Cache; @@ -46,7 +47,7 @@ public override async ValueTask GetAsync(string key, Cancellation { try { - return GetCore(await this.cache.GetAsync(key, cancellationToken).ConfigureAwait(false)); + return GetCore(await this.cache.GetAsync(key, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext)); } catch (Exception ex) { @@ -92,7 +93,7 @@ public override async ValueTask SetAsync(string key, ProjectConfig config, Cance { if (SetCore(config) is { } serializedConfig) { - await this.cache.SetAsync(key, serializedConfig, cancellationToken).ConfigureAwait(false); + await this.cache.SetAsync(key, serializedConfig, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } } catch (Exception ex) diff --git a/src/ConfigCatClient/ConfigCatClient.cs b/src/ConfigCatClient/ConfigCatClient.cs index dff33020..23be55c2 100644 --- a/src/ConfigCatClient/ConfigCatClient.cs +++ b/src/ConfigCatClient/ConfigCatClient.cs @@ -9,6 +9,7 @@ using ConfigCat.Client.Configuration; using ConfigCat.Client.Evaluation; using ConfigCat.Client.Override; +using ConfigCat.Client.Shims; using ConfigCat.Client.Utils; namespace ConfigCat.Client; @@ -22,6 +23,19 @@ public sealed class ConfigCatClient : IConfigCatClient internal static readonly ConfigCatClientCache Instances = new(); +#if NETSTANDARD + /// + /// Returns an object that can be used to configure the SDK to work on platforms that are not fully standards compliant. + /// + /// + /// Configuration is only possible before the first instance is created. + /// + public +#else + internal +#endif + static readonly PlatformCompatibilityOptions PlatformCompatibilityOptions = new(); + private readonly string? sdkKey; // may be null in case of testing private readonly EvaluationServices evaluationServices; private readonly IConfigService configService; @@ -76,6 +90,10 @@ public LogLevel LogLevel internal ConfigCatClient(string sdkKey, ConfigCatClientOptions options) { +#if NETSTANDARD + PlatformCompatibilityOptions.Freeze(); +#endif + this.sdkKey = sdkKey; this.hooks = options.YieldHooks(); @@ -114,6 +132,7 @@ internal ConfigCatClient(string sdkKey, ConfigCatClientOptions options) GetProductVersion(pollingMode), logger, options.ConfigFetcher + ?? PlatformCompatibilityOptions.configFetcherFactory?.Invoke(options.HttpClientHandler) ?? ConfigCatClientOptions.CreateDefaultConfigFetcher(options.HttpClientHandler), options.IsCustomBaseUrl, options.HttpTimeout), @@ -128,6 +147,10 @@ internal ConfigCatClient(string sdkKey, ConfigCatClientOptions options) internal ConfigCatClient(IConfigService configService, IConfigCatLogger logger, IRolloutEvaluator evaluator, OverrideBehaviour? overrideBehaviour = null, IOverrideDataSource? overrideDataSource = null, Hooks? hooks = null) { +#if NETSTANDARD + PlatformCompatibilityOptions.Freeze(); +#endif + this.hooks = hooks ?? NullHooks.Instance; this.hooks.SetSender(this); var hooksWrapper = new SafeHooksWrapper(this.hooks); @@ -215,10 +238,11 @@ private void Dispose(bool disposing) var localFileDataSource = this.overrideDataSource as IDisposable; if (configService is not null || localFileDataSource is not null) { - Task.Run(() => + TaskShim.Current.Run(() => { configService?.Dispose(); localFileDataSource?.Dispose(); + return TaskShim.CompletedTask; }); } } @@ -324,7 +348,7 @@ public async Task GetValueAsync(string key, T defaultValue, User? user = n user ??= this.defaultUser; try { - settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); + settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); evaluationDetails = ConfigEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, Logger); value = evaluationDetails.Value; } @@ -399,7 +423,7 @@ public async Task> GetValueDetailsAsync(string key, T de user ??= this.defaultUser; try { - settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); + settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); evaluationDetails = ConfigEvaluator.Evaluate(settings.Value, key, defaultValue, user, settings.RemoteConfig, Logger); } catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) @@ -444,7 +468,7 @@ public async Task> GetAllKeysAsync(CancellationToken const string defaultReturnValue = "empty collection"; try { - var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); + var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); if (!EvaluationHelper.CheckSettingsAvailable(settings.Value, Logger, defaultReturnValue)) { return ArrayUtils.EmptyArray(); @@ -506,7 +530,7 @@ public async Task> GetAllKeysAsync(CancellationToken user ??= this.defaultUser; try { - var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); + var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); evaluationDetailsArray = ConfigEvaluator.EvaluateAll(settings.Value, user, settings.RemoteConfig, Logger, defaultReturnValue, out evaluationExceptions); result = evaluationDetailsArray.ToDictionary(details => details.Key, details => details.Value); } @@ -574,7 +598,7 @@ public async Task> GetAllValueDetailsAsync(User user ??= this.defaultUser; try { - var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); + var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); evaluationDetailsArray = ConfigEvaluator.EvaluateAll(settings.Value, user, settings.RemoteConfig, Logger, defaultReturnValue, out evaluationExceptions); } catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) @@ -647,7 +671,7 @@ public async Task> GetAllValueDetailsAsync(User const string defaultReturnValue = "null"; try { - var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(false); + var settings = await GetSettingsAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); return EvaluationHelper.GetKeyAndValue(settings.Value, variationId, Logger, defaultReturnValue); } catch (Exception ex) @@ -677,7 +701,7 @@ public async Task ForceRefreshAsync(CancellationToken cancellatio { try { - return await this.configService.RefreshConfigAsync(cancellationToken).ConfigureAwait(false); + return await this.configService.RefreshConfigAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) { @@ -731,16 +755,16 @@ private async Task GetSettingsAsync(CancellationToken switch (this.overrideBehaviour) { case null: - return await GetRemoteConfigAsync(cancellationToken).ConfigureAwait(false); + return await GetRemoteConfigAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); case OverrideBehaviour.LocalOnly: - return new SettingsWithRemoteConfig(await this.overrideDataSource!.GetOverridesAsync(cancellationToken).ConfigureAwait(false), remoteConfig: null); + return new SettingsWithRemoteConfig(await this.overrideDataSource!.GetOverridesAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext), remoteConfig: null); case OverrideBehaviour.LocalOverRemote: - local = await this.overrideDataSource!.GetOverridesAsync(cancellationToken).ConfigureAwait(false); - remote = await GetRemoteConfigAsync(cancellationToken).ConfigureAwait(false); + local = await this.overrideDataSource!.GetOverridesAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); + remote = await GetRemoteConfigAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); return new SettingsWithRemoteConfig(remote.Value.MergeOverwriteWith(local), remote.RemoteConfig); case OverrideBehaviour.RemoteOverLocal: - local = await this.overrideDataSource!.GetOverridesAsync(cancellationToken).ConfigureAwait(false); - remote = await GetRemoteConfigAsync(cancellationToken).ConfigureAwait(false); + local = await this.overrideDataSource!.GetOverridesAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); + remote = await GetRemoteConfigAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); return new SettingsWithRemoteConfig(local.MergeOverwriteWith(remote.Value), remote.RemoteConfig); default: throw new InvalidOperationException(); // execution should never get here @@ -748,7 +772,7 @@ private async Task GetSettingsAsync(CancellationToken async Task GetRemoteConfigAsync(CancellationToken cancellationToken) { - var config = await this.configService.GetConfigAsync(cancellationToken).ConfigureAwait(false); + var config = await this.configService.GetConfigAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); var settings = !config.IsEmpty ? config.Config.Settings : null; return new SettingsWithRemoteConfig(settings, config); } diff --git a/src/ConfigCatClient/ConfigCatClient.csproj b/src/ConfigCatClient/ConfigCatClient.csproj index fa1df11c..002bc4d4 100644 --- a/src/ConfigCatClient/ConfigCatClient.csproj +++ b/src/ConfigCatClient/ConfigCatClient.csproj @@ -97,10 +97,6 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs b/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs index aeb3b19b..0cdb4ac8 100644 --- a/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs +++ b/src/ConfigCatClient/ConfigService/AutoPollConfigService.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using ConfigCat.Client.Cache; using ConfigCat.Client.Configuration; +using ConfigCat.Client.Shims; namespace ConfigCat.Client.ConfigService; @@ -45,7 +46,7 @@ internal AutoPollConfigService( { // In Auto Polling mode, maxInitWaitTime takes precedence over waiting for initial cache sync-up, that is, // ClientReady is always raised after maxInitWaitTime has passed, regardless of whether initial cache sync-up has finished or not. - await initializationTask.ConfigureAwait(false); + await initializationTask.ConfigureAwait(TaskShim.ContinueOnCapturedContext); return GetCacheState(this.ConfigCache.LocalCachedConfig); }); @@ -107,8 +108,8 @@ private async Task WaitForInitializationAsync(CancellationToken cancellati { try { - await Task.Delay(this.maxInitWaitTime, this.initSignalCancellationTokenSource.Token) - .WaitAsync(cancellationToken).ConfigureAwait(false); + await TaskShim.Current.Delay(this.maxInitWaitTime, this.initSignalCancellationTokenSource.Token) + .WaitAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); return false; } @@ -140,16 +141,16 @@ public async ValueTask GetConfigAsync(CancellationToken cancellat { if (!IsOffline && !IsInitialized) { - var cachedConfig = await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false); + var cachedConfig = await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); if (!cachedConfig.IsExpired(expiration: this.pollInterval)) { return cachedConfig; } - await InitializationTask.WaitAsync(cancellationToken).ConfigureAwait(false); + await InitializationTask.WaitAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } - return await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false); + return await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } protected override void OnConfigFetched(in FetchResult fetchResult, bool isInitiatedByUser) @@ -172,7 +173,7 @@ protected override void SetOfflineCoreSynchronized() private void StartScheduler(Task? initialCacheSyncUpTask, CancellationToken stopToken) { - Task.Run(async () => + TaskShim.Current.Run(async () => { var isFirstIteration = true; @@ -183,7 +184,7 @@ private void StartScheduler(Task? initialCacheSyncUpTask, Cancell var scheduledNextTime = DateTime.UtcNow.Add(this.pollInterval); try { - await PollCoreAsync(isFirstIteration, initialCacheSyncUpTask, stopToken).ConfigureAwait(false); + await PollCoreAsync(isFirstIteration, initialCacheSyncUpTask, stopToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -193,7 +194,7 @@ private void StartScheduler(Task? initialCacheSyncUpTask, Cancell var realNextTime = scheduledNextTime.Subtract(DateTime.UtcNow); if (realNextTime > TimeSpan.Zero) { - await Task.Delay(realNextTime, stopToken).ConfigureAwait(false); + await TaskShim.Current.Delay(realNextTime, stopToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } } catch (OperationCanceledException) @@ -208,6 +209,8 @@ private void StartScheduler(Task? initialCacheSyncUpTask, Cancell isFirstIteration = false; initialCacheSyncUpTask = null; // allow GC to collect the task and its result } + + return default(object); }, stopToken); } @@ -216,14 +219,14 @@ private async ValueTask PollCoreAsync(bool isFirstIteration, Task if (isFirstIteration) { var latestConfig = initialCacheSyncUpTask is not null - ? await initialCacheSyncUpTask.WaitAsync(cancellationToken).ConfigureAwait(false) - : await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false); + ? await initialCacheSyncUpTask.WaitAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext) + : await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); if (latestConfig.IsExpired(expiration: this.pollInterval)) { if (!IsOffline) { - await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: false, cancellationToken).ConfigureAwait(false); + await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: false, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } } else @@ -235,8 +238,8 @@ private async ValueTask PollCoreAsync(bool isFirstIteration, Task { if (!IsOffline) { - var latestConfig = await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false); - await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: false, cancellationToken).ConfigureAwait(false); + var latestConfig = await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); + await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: false, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } } } diff --git a/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs b/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs index 7423ef46..42a561d8 100644 --- a/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs +++ b/src/ConfigCatClient/ConfigService/ConfigServiceBase.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using ConfigCat.Client.Cache; +using ConfigCat.Client.Shims; #if NET45 using ConfigWithFetchResult = System.Tuple; @@ -126,8 +127,8 @@ public virtual async ValueTask RefreshConfigAsync(CancellationTok { if (!IsOffline) { - var latestConfig = await this.ConfigCache.GetAsync(this.CacheKey, cancellationToken).ConfigureAwait(false); - var configWithFetchResult = await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: true, cancellationToken).ConfigureAwait(false); + var latestConfig = await this.ConfigCache.GetAsync(this.CacheKey, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); + var configWithFetchResult = await RefreshConfigCoreAsync(latestConfig, isInitiatedByUser: true, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); return RefreshResult.From(configWithFetchResult.Item2); } else @@ -139,12 +140,12 @@ public virtual async ValueTask RefreshConfigAsync(CancellationTok protected async Task RefreshConfigCoreAsync(ProjectConfig latestConfig, bool isInitiatedByUser, CancellationToken cancellationToken) { - var fetchResult = await this.ConfigFetcher.FetchAsync(latestConfig, cancellationToken).ConfigureAwait(false); + var fetchResult = await this.ConfigFetcher.FetchAsync(latestConfig, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); if (fetchResult.IsSuccess || fetchResult.Config.TimeStamp > latestConfig.TimeStamp && (!fetchResult.Config.IsEmpty || latestConfig.IsEmpty)) { - await this.ConfigCache.SetAsync(this.CacheKey, fetchResult.Config, cancellationToken).ConfigureAwait(false); + await this.ConfigCache.SetAsync(this.CacheKey, fetchResult.Config, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); latestConfig = fetchResult.Config; } @@ -246,7 +247,7 @@ protected Task SyncUpWithCacheAsync(CancellationToken cancellatio protected async Task GetReadyTask(TState state, Func> waitForReadyAsync) { ClientCacheState cacheState; - try { cacheState = await waitForReadyAsync(state).ConfigureAwait(false); } + try { cacheState = await waitForReadyAsync(state).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } finally { lock (this.syncObj) diff --git a/src/ConfigCatClient/ConfigService/DefaultConfigFetcher.cs b/src/ConfigCatClient/ConfigService/DefaultConfigFetcher.cs index 744ff97a..de2c8b11 100644 --- a/src/ConfigCatClient/ConfigService/DefaultConfigFetcher.cs +++ b/src/ConfigCatClient/ConfigService/DefaultConfigFetcher.cs @@ -4,6 +4,7 @@ using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; +using ConfigCat.Client.Shims; namespace ConfigCat.Client; @@ -51,11 +52,11 @@ private Task BeginFetchOrJoinPending(ProjectConfig lastConfig) { lock (this.syncObj) { - this.pendingFetch ??= Task.Run(async () => + this.pendingFetch ??= TaskShim.Current.Run(async () => { try { - return await FetchInternalAsync(lastConfig).ConfigureAwait(false); + return await FetchInternalAsync(lastConfig).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } finally { @@ -77,7 +78,7 @@ private async ValueTask FetchInternalAsync(ProjectConfig lastConfig Exception errorException; try { - var deserializedResponse = await FetchRequestAsync(lastConfig.HttpETag, this.requestUri).ConfigureAwait(false); + var deserializedResponse = await FetchRequestAsync(lastConfig.HttpETag, this.requestUri).ConfigureAwait(TaskShim.ContinueOnCapturedContext); var response = deserializedResponse.Response; @@ -154,7 +155,7 @@ private async ValueTask FetchRequestAsync(string? httpETag { var request = new FetchRequest(this.requestUri, httpETag, this.sdkInfoHeader, this.timeout); - var response = await this.configFetcher.FetchAsync(request, this.cancellationTokenSource.Token).ConfigureAwait(false); + var response = await this.configFetcher.FetchAsync(request, this.cancellationTokenSource.Token).ConfigureAwait(TaskShim.ContinueOnCapturedContext); if (response.StatusCode == HttpStatusCode.OK) { diff --git a/src/ConfigCatClient/ConfigService/HttpClientConfigFetcher.cs b/src/ConfigCatClient/ConfigService/HttpClientConfigFetcher.cs index 764d7114..546d6bf3 100644 --- a/src/ConfigCatClient/ConfigService/HttpClientConfigFetcher.cs +++ b/src/ConfigCatClient/ConfigService/HttpClientConfigFetcher.cs @@ -5,6 +5,7 @@ using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; +using ConfigCat.Client.Shims; namespace ConfigCat.Client; @@ -79,14 +80,14 @@ public async Task FetchAsync(FetchRequest request, CancellationTo try { - var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); if (httpResponse.StatusCode == HttpStatusCode.OK) { #if NET5_0_OR_GREATER - var httpResponseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var httpResponseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); #else - var httpResponseBody = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + var httpResponseBody = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(TaskShim.ContinueOnCapturedContext); #endif return new FetchResponse(httpResponse.StatusCode, httpResponse.ReasonPhrase, httpResponse.Headers.ETag?.Tag, httpResponseBody); diff --git a/src/ConfigCatClient/ConfigService/LazyLoadConfigService.cs b/src/ConfigCatClient/ConfigService/LazyLoadConfigService.cs index d74a39c5..59536245 100644 --- a/src/ConfigCatClient/ConfigService/LazyLoadConfigService.cs +++ b/src/ConfigCatClient/ConfigService/LazyLoadConfigService.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using ConfigCat.Client.Cache; +using ConfigCat.Client.Shims; namespace ConfigCat.Client.ConfigService; @@ -15,7 +16,7 @@ internal LazyLoadConfigService(IConfigFetcher configFetcher, CacheParameters cac this.cacheTimeToLive = cacheTimeToLive; var initialCacheSyncUpTask = SyncUpWithCacheAsync(WaitForReadyCancellationToken); - ReadyTask = GetReadyTask(initialCacheSyncUpTask, async initialCacheSyncUpTask => GetCacheState(await initialCacheSyncUpTask.ConfigureAwait(false))); + ReadyTask = GetReadyTask(initialCacheSyncUpTask, async initialCacheSyncUpTask => GetCacheState(await initialCacheSyncUpTask.ConfigureAwait(TaskShim.ContinueOnCapturedContext))); } public Task ReadyTask { get; } @@ -43,7 +44,7 @@ public ProjectConfig GetConfig() public async ValueTask GetConfigAsync(CancellationToken cancellationToken = default) { - var cachedConfig = await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(false); + var cachedConfig = await this.ConfigCache.GetAsync(base.CacheKey, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); if (cachedConfig.IsExpired(expiration: this.cacheTimeToLive)) { @@ -54,7 +55,7 @@ public async ValueTask GetConfigAsync(CancellationToken cancellat if (!IsOffline) { - var configWithFetchResult = await RefreshConfigCoreAsync(cachedConfig, isInitiatedByUser: false, cancellationToken).ConfigureAwait(false); + var configWithFetchResult = await RefreshConfigCoreAsync(cachedConfig, isInitiatedByUser: false, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); return configWithFetchResult.Item1; } } diff --git a/src/ConfigCatClient/ConfigService/ManualPollConfigService.cs b/src/ConfigCatClient/ConfigService/ManualPollConfigService.cs index 1dcb816e..b2a9ad3e 100644 --- a/src/ConfigCatClient/ConfigService/ManualPollConfigService.cs +++ b/src/ConfigCatClient/ConfigService/ManualPollConfigService.cs @@ -1,6 +1,7 @@ using System.Threading; using System.Threading.Tasks; using ConfigCat.Client.Cache; +using ConfigCat.Client.Shims; namespace ConfigCat.Client.ConfigService; @@ -10,7 +11,7 @@ internal ManualPollConfigService(IConfigFetcher configFetcher, CacheParameters c : base(configFetcher, cacheParameters, logger, isOffline, hooks) { var initialCacheSyncUpTask = SyncUpWithCacheAsync(WaitForReadyCancellationToken); - ReadyTask = GetReadyTask(initialCacheSyncUpTask, async initialCacheSyncUpTask => GetCacheState(await initialCacheSyncUpTask.ConfigureAwait(false))); + ReadyTask = GetReadyTask(initialCacheSyncUpTask, async initialCacheSyncUpTask => GetCacheState(await initialCacheSyncUpTask.ConfigureAwait(TaskShim.ContinueOnCapturedContext))); } public Task ReadyTask { get; } diff --git a/src/ConfigCatClient/Configuration/PlatformCompatibilityOptions.cs b/src/ConfigCatClient/Configuration/PlatformCompatibilityOptions.cs new file mode 100644 index 00000000..359475cb --- /dev/null +++ b/src/ConfigCatClient/Configuration/PlatformCompatibilityOptions.cs @@ -0,0 +1,62 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using ConfigCat.Client.Shims; + +namespace ConfigCat.Client.Configuration; + +/// +/// Provides compatibility options to make the SDK work on platforms that are not fully standards compliant. +/// +#if NETSTANDARD +public sealed class PlatformCompatibilityOptions +#else +internal sealed class PlatformCompatibilityOptions +#endif +{ + internal bool continueOnCapturedContext; + + internal Func? configFetcherFactory; + + internal TaskShim taskShim = TaskShim.Default; + +#if NETSTANDARD + private volatile bool frozen; + + internal void Freeze() => this.frozen = true; + + private void EnsureNotFrozen() + { + if (this.frozen) + { + throw new InvalidOperationException($"Platform compatibility options cannot be changed after the first instance of ${nameof(ConfigCatClient)} has been created."); + } + } + + /// + /// Configures the SDK to run in a Unity WebGL application. To make this actually work, it is necessary to provide an implementation for + /// and an implementation for to workaround + /// the restrictions of the Unity WebGL environments. + /// + /// The implementation of a few -related APIs used by the SDK. + /// The config fetcher implementation (based on e.g. . + public void EnableUnityWebGLCompatibility(TaskShim taskShim, Func configFetcherFactory) + { + if (configFetcherFactory is null) + { + throw new ArgumentNullException(nameof(configFetcherFactory)); + } + + if (taskShim is null) + { + throw new ArgumentNullException(nameof(taskShim)); + } + + EnsureNotFrozen(); + + this.continueOnCapturedContext = true; + this.configFetcherFactory = (_) => configFetcherFactory(); + this.taskShim = taskShim; + } +#endif +} diff --git a/src/ConfigCatClient/Extensions/TaskExtensions.cs b/src/ConfigCatClient/Extensions/TaskExtensions.cs index bc9adb85..5d1ded69 100644 --- a/src/ConfigCatClient/Extensions/TaskExtensions.cs +++ b/src/ConfigCatClient/Extensions/TaskExtensions.cs @@ -1,3 +1,5 @@ +using ConfigCat.Client.Shims; + namespace System.Threading.Tasks; internal static class TaskExtensions @@ -51,16 +53,16 @@ static async Task Awaited(Task task, CancellationToken cancellationToken) #if !NET45 var cancellationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var cancellationTask = cancellationTcs.Task; - var tokenRegistration = cancellationToken.Register(() => cancellationTcs.TrySetCanceled(cancellationToken), useSynchronizationContext: false); + var tokenRegistration = cancellationToken.Register(() => cancellationTcs.TrySetCanceled(cancellationToken), useSynchronizationContext: TaskShim.ContinueOnCapturedContext); #else var cancellationTcs = new TaskCompletionSource(); var cancellationTask = cancellationTcs.Task.ContinueWith(static _ => default!, cancellationToken, TaskContinuationOptions.None, TaskScheduler.Default); - var tokenRegistration = cancellationToken.Register(() => cancellationTcs.TrySetResult(default!), useSynchronizationContext: false); + var tokenRegistration = cancellationToken.Register(() => cancellationTcs.TrySetResult(default!), useSynchronizationContext: TaskShim.ContinueOnCapturedContext); #endif using (tokenRegistration) { - var completedTask = await Task.WhenAny(task, cancellationTask).ConfigureAwait(false); + var completedTask = await Task.WhenAny(task, cancellationTask).ConfigureAwait(TaskShim.ContinueOnCapturedContext); if (completedTask is Task taskWithResult) { return taskWithResult.GetAwaiter().GetResult(); diff --git a/src/ConfigCatClient/Override/LocalFileDataSource.cs b/src/ConfigCatClient/Override/LocalFileDataSource.cs index d80b295e..f48351c3 100644 --- a/src/ConfigCatClient/Override/LocalFileDataSource.cs +++ b/src/ConfigCatClient/Override/LocalFileDataSource.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using ConfigCat.Client.Shims; using ConfigCat.Client.Utils; namespace ConfigCat.Client.Override; @@ -55,7 +56,7 @@ private void StartWatch() // It's better to acquire a CancellationToken here because the getter might throw if CTS got disposed. var cancellationToken = this.cancellationTokenSource.Token; - Task.Run(async () => + TaskShim.Current.Run(async () => { this.logger.LocalFileDataSourceStartsWatchingFile(this.fullPath); @@ -65,14 +66,14 @@ private void StartWatch() { try { - await WatchCoreAsync(cancellationToken).ConfigureAwait(false); + await WatchCoreAsync(cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } catch (Exception ex) when (ex is not OperationCanceledException) { this.logger.LocalFileDataSourceErrorDuringWatching(this.fullPath, ex); } - await Task.Delay(FILE_POLL_INTERVAL, cancellationToken).ConfigureAwait(false); + await TaskShim.Current.Delay(TimeSpan.FromMilliseconds(FILE_POLL_INTERVAL), cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } catch (OperationCanceledException) { @@ -83,6 +84,8 @@ private void StartWatch() this.logger.LocalFileDataSourceErrorDuringWatching(this.fullPath, ex); } } + + return default(object); }); } @@ -92,7 +95,7 @@ private async ValueTask WatchCoreAsync(CancellationToken cancellationToken) if (lastWriteTime > this.fileLastWriteTime) { this.logger.LocalFileDataSourceReloadsFile(this.fullPath); - await ReloadFileAsync(isAsync: true, cancellationToken).ConfigureAwait(false); + await ReloadFileAsync(isAsync: true, cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } } @@ -125,7 +128,7 @@ private async Task ReloadFileAsync(bool isAsync, CancellationToken cancellationT if (isAsync) { - await Task.Delay(WAIT_TIME_FOR_UNLOCK, cancellationToken).ConfigureAwait(false); + await TaskShim.Current.Delay(TimeSpan.FromMilliseconds(WAIT_TIME_FOR_UNLOCK), cancellationToken).ConfigureAwait(TaskShim.ContinueOnCapturedContext); } else { diff --git a/src/ConfigCatClient/Shims/DefaultTaskShim.cs b/src/ConfigCatClient/Shims/DefaultTaskShim.cs new file mode 100644 index 00000000..baac857e --- /dev/null +++ b/src/ConfigCatClient/Shims/DefaultTaskShim.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ConfigCat.Client.Shims; + +internal sealed class DefaultTaskShim : TaskShim +{ + public override Task Run(Func> function, CancellationToken cancellationToken = default) + => Task.Run(function, cancellationToken); + + public override Task Delay(TimeSpan delay, CancellationToken cancellationToken = default) + => Task.Delay(delay, cancellationToken); +} diff --git a/src/ConfigCatClient/Shims/TaskShim.cs b/src/ConfigCatClient/Shims/TaskShim.cs new file mode 100644 index 00000000..762d1fce --- /dev/null +++ b/src/ConfigCatClient/Shims/TaskShim.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ConfigCat.Client.Configuration; + +namespace ConfigCat.Client.Shims; + +/// +/// Defines an abstraction over a few -related APIs used by the SDK so that +/// consumers can override their behavior in some constrained runtime environments. +/// +#if NETSTANDARD +public abstract class TaskShim +#else +internal abstract class TaskShim +#endif +{ + /// + /// Provides an instance of the default implementation of the class, which just simply calls the built-in methods. + /// + public static readonly TaskShim Default = new DefaultTaskShim(); + + /// + /// Returns the currently used , configured via . + /// + public static TaskShim Current => ConfigCatClient.PlatformCompatibilityOptions.taskShim; + + internal static readonly Task CompletedTask = Task.FromResult(null); + + internal static bool ContinueOnCapturedContext => ConfigCatClient.PlatformCompatibilityOptions.continueOnCapturedContext; + + /// + /// Initializes a new instance of the class. + /// + protected TaskShim() { } + + /// + public abstract Task Run(Func> function, CancellationToken cancellationToken = default); + + /// + public abstract Task Delay(TimeSpan delay, CancellationToken cancellationToken = default); +} From 2eb10f02fbfe7f407d7ab33e6e09f769fc05b928 Mon Sep 17 00:00:00 2001 From: Adam Simon Date: Sat, 27 Jul 2024 21:32:12 +0200 Subject: [PATCH 07/14] Add MAUI sample app --- .../Adapters/ConfigCatToMSLoggerAdapter.cs | 2 +- samples/ASP.NETCore/WebApplication/Program.cs | 7 +- samples/BlazorWasm/Program.cs | 3 +- samples/MAUI/App.xaml | 14 + samples/MAUI/App.xaml.cs | 11 + samples/MAUI/AppShell.xaml | 15 + samples/MAUI/AppShell.xaml.cs | 9 + samples/MAUI/MainPage.xaml | 37 ++ samples/MAUI/MainPage.xaml.cs | 37 ++ samples/MAUI/MauiProgram.cs | 39 ++ samples/MAUI/MauiSample.csproj | 66 +++ samples/MAUI/MauiSample.sln | 44 ++ .../Platforms/Android/AndroidManifest.xml | 6 + .../MAUI/Platforms/Android/MainActivity.cs | 9 + .../MAUI/Platforms/Android/MainApplication.cs | 14 + .../Android/Resources/values/colors.xml | 6 + samples/MAUI/Platforms/Windows/App.xaml | 8 + samples/MAUI/Platforms/Windows/App.xaml.cs | 23 + .../Platforms/Windows/Package.appxmanifest | 47 ++ samples/MAUI/Platforms/Windows/app.manifest | 15 + samples/MAUI/Platforms/iOS/AppDelegate.cs | 8 + samples/MAUI/Platforms/iOS/Info.plist | 32 ++ samples/MAUI/Platforms/iOS/Program.cs | 14 + samples/MAUI/README.md | 5 + samples/MAUI/Resources/AppIcon/appicon.svg | 4 + samples/MAUI/Resources/AppIcon/appiconfg.svg | 8 + .../MAUI/Resources/Fonts/OpenSans-Regular.ttf | Bin 0 -> 107184 bytes .../Resources/Fonts/OpenSans-Semibold.ttf | Bin 0 -> 111168 bytes samples/MAUI/Resources/Images/configcat.png | Bin 0 -> 2926 bytes samples/MAUI/Resources/Raw/AboutAssets.txt | 15 + samples/MAUI/Resources/Splash/splash.svg | 8 + samples/MAUI/Resources/Styles/Colors.xaml | 45 ++ samples/MAUI/Resources/Styles/Styles.xaml | 427 ++++++++++++++++++ 33 files changed, 972 insertions(+), 6 deletions(-) create mode 100644 samples/MAUI/App.xaml create mode 100644 samples/MAUI/App.xaml.cs create mode 100644 samples/MAUI/AppShell.xaml create mode 100644 samples/MAUI/AppShell.xaml.cs create mode 100644 samples/MAUI/MainPage.xaml create mode 100644 samples/MAUI/MainPage.xaml.cs create mode 100644 samples/MAUI/MauiProgram.cs create mode 100644 samples/MAUI/MauiSample.csproj create mode 100644 samples/MAUI/MauiSample.sln create mode 100644 samples/MAUI/Platforms/Android/AndroidManifest.xml create mode 100644 samples/MAUI/Platforms/Android/MainActivity.cs create mode 100644 samples/MAUI/Platforms/Android/MainApplication.cs create mode 100644 samples/MAUI/Platforms/Android/Resources/values/colors.xml create mode 100644 samples/MAUI/Platforms/Windows/App.xaml create mode 100644 samples/MAUI/Platforms/Windows/App.xaml.cs create mode 100644 samples/MAUI/Platforms/Windows/Package.appxmanifest create mode 100644 samples/MAUI/Platforms/Windows/app.manifest create mode 100644 samples/MAUI/Platforms/iOS/AppDelegate.cs create mode 100644 samples/MAUI/Platforms/iOS/Info.plist create mode 100644 samples/MAUI/Platforms/iOS/Program.cs create mode 100644 samples/MAUI/README.md create mode 100644 samples/MAUI/Resources/AppIcon/appicon.svg create mode 100644 samples/MAUI/Resources/AppIcon/appiconfg.svg create mode 100644 samples/MAUI/Resources/Fonts/OpenSans-Regular.ttf create mode 100644 samples/MAUI/Resources/Fonts/OpenSans-Semibold.ttf create mode 100644 samples/MAUI/Resources/Images/configcat.png create mode 100644 samples/MAUI/Resources/Raw/AboutAssets.txt create mode 100644 samples/MAUI/Resources/Splash/splash.svg create mode 100644 samples/MAUI/Resources/Styles/Colors.xaml create mode 100644 samples/MAUI/Resources/Styles/Styles.xaml diff --git a/samples/ASP.NETCore/WebApplication/Adapters/ConfigCatToMSLoggerAdapter.cs b/samples/ASP.NETCore/WebApplication/Adapters/ConfigCatToMSLoggerAdapter.cs index 9a9a1504..124ad32a 100644 --- a/samples/ASP.NETCore/WebApplication/Adapters/ConfigCatToMSLoggerAdapter.cs +++ b/samples/ASP.NETCore/WebApplication/Adapters/ConfigCatToMSLoggerAdapter.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using Microsoft.Extensions.Logging; -namespace WebApplication.Adapters; +namespace ConfigCat.Client.Extensions.Adapters; public class ConfigCatToMSLoggerAdapter : ConfigCat.Client.IConfigCatLogger { diff --git a/samples/ASP.NETCore/WebApplication/Program.cs b/samples/ASP.NETCore/WebApplication/Program.cs index ca84cd6e..0cf3f478 100644 --- a/samples/ASP.NETCore/WebApplication/Program.cs +++ b/samples/ASP.NETCore/WebApplication/Program.cs @@ -1,11 +1,10 @@ -using System; +using System; using ConfigCat.Client; +using ConfigCat.Client.Extensions.Adapters; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using WebApplication.Adapters; var builder = Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder(args); @@ -22,7 +21,7 @@ { var logger = sp.GetRequiredService>(); - return ConfigCatClient.Get(configCatSdkKey, options => + return ConfigCatClient.Get(configCatSdkKey!, options => { options.PollingMode = PollingModes.LazyLoad(cacheTimeToLive: TimeSpan.FromSeconds(120)); options.Logger = new ConfigCatToMSLoggerAdapter(logger); diff --git a/samples/BlazorWasm/Program.cs b/samples/BlazorWasm/Program.cs index a43ffea1..26ee80cc 100644 --- a/samples/BlazorWasm/Program.cs +++ b/samples/BlazorWasm/Program.cs @@ -1,5 +1,6 @@ using BlazorWasm; using ConfigCat.Client; +using ConfigCat.Client.Extensions.Adapters; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; @@ -21,7 +22,7 @@ return ConfigCatClient.Get("PKDVCLf-Hq-h-kCzMp-L7Q/HhOWfwVtZ0mb30i9wi17GQ", options => { options.PollingMode = PollingModes.AutoPoll(); - options.Logger = new WebApplication.Adapters.ConfigCatToMSLoggerAdapter(logger); + options.Logger = new ConfigCatToMSLoggerAdapter(logger); }); }); diff --git a/samples/MAUI/App.xaml b/samples/MAUI/App.xaml new file mode 100644 index 00000000..d105f428 --- /dev/null +++ b/samples/MAUI/App.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/samples/MAUI/App.xaml.cs b/samples/MAUI/App.xaml.cs new file mode 100644 index 00000000..05ee42cb --- /dev/null +++ b/samples/MAUI/App.xaml.cs @@ -0,0 +1,11 @@ +namespace MauiSample; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + + MainPage = new AppShell(); + } +} diff --git a/samples/MAUI/AppShell.xaml b/samples/MAUI/AppShell.xaml new file mode 100644 index 00000000..9a383472 --- /dev/null +++ b/samples/MAUI/AppShell.xaml @@ -0,0 +1,15 @@ + + + + + + diff --git a/samples/MAUI/AppShell.xaml.cs b/samples/MAUI/AppShell.xaml.cs new file mode 100644 index 00000000..ad4823a1 --- /dev/null +++ b/samples/MAUI/AppShell.xaml.cs @@ -0,0 +1,9 @@ +namespace MauiSample; + +public partial class AppShell : Shell +{ + public AppShell() + { + InitializeComponent(); + } +} diff --git a/samples/MAUI/MainPage.xaml b/samples/MAUI/MainPage.xaml new file mode 100644 index 00000000..40b2dc36 --- /dev/null +++ b/samples/MAUI/MainPage.xaml @@ -0,0 +1,37 @@ + + + + + + + +