diff --git a/README.md b/README.md index 2f27d69..d0b8ed7 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ After that, `GetFlag(...)` and `GetFlagsForBootstrap(...)` evaluate flags locall The SDK keeps definitions fresh in the background using push sync by default. You can switch to polling mode instead. It also batches tracking-related events and applies internal dedupe/rate limiting to optimize tracking traffic. +Check-event deduplication preserves context JSON types and array order while ignoring object-key order, including nested objects. ## Fallback provider diff --git a/src/Reflag/Internal/CanonicalContextJson.cs b/src/Reflag/Internal/CanonicalContextJson.cs new file mode 100644 index 0000000..8a71740 --- /dev/null +++ b/src/Reflag/Internal/CanonicalContextJson.cs @@ -0,0 +1,46 @@ +using System.Text; +using System.Text.Json; + +namespace Reflag.Internal; + +internal static class CanonicalContextJson +{ + public static string Serialize(IReadOnlyDictionary context) + { + using var document = JsonDocument.Parse(JsonSerializer.Serialize(context)); + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + Write(writer, document.RootElement); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void Write(Utf8JsonWriter writer, JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.Object: + writer.WriteStartObject(); + foreach (var property in value.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal)) + { + writer.WritePropertyName(property.Name); + Write(writer, property.Value); + } + writer.WriteEndObject(); + break; + case JsonValueKind.Array: + writer.WriteStartArray(); + foreach (var item in value.EnumerateArray()) + { + Write(writer, item); + } + writer.WriteEndArray(); + break; + default: + value.WriteTo(writer); + break; + } + } +} diff --git a/src/Reflag/ReflagClient.cs b/src/Reflag/ReflagClient.cs index 6a13ac8..122d92b 100644 --- a/src/Reflag/ReflagClient.cs +++ b/src/Reflag/ReflagClient.cs @@ -1042,17 +1042,7 @@ await _batchBuffer.AddAsync( private static string BuildContextKey(IReadOnlyDictionary context) { - var flattenedContext = FlagEvaluation.FlattenJson(context); - if (flattenedContext.Count == 0) - { - return string.Empty; - } - - return string.Join( - "&", - flattenedContext - .OrderBy(static pair => pair.Key, StringComparer.Ordinal) - .Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")); + return CanonicalContextJson.Serialize(context); } private void SyncFlagOverridesNoLock() diff --git a/tests/Reflag.Tests/CanonicalContextJsonTests.cs b/tests/Reflag.Tests/CanonicalContextJsonTests.cs new file mode 100644 index 0000000..144d7df --- /dev/null +++ b/tests/Reflag.Tests/CanonicalContextJsonTests.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using Reflag.Internal; +using Xunit; + +namespace Reflag.Tests; + +public sealed class CanonicalContextJsonTests +{ + [Fact] + public async Task Check_deduplication_preserves_context_types_and_ignores_object_key_order() + { + var transport = new TestTransport(); + transport.EnqueueGetJson("{\"success\":true,\"features\":[]}"); + await using var client = new ReflagClient(new ReflagClientOptions + { + SecretKey = "validSecretKeyWithMoreThan22Chars", + HttpClient = transport.CreateHttpClient(), + FlagsSyncMode = ReflagFlagsSyncMode.Polling, + }); + await client.InitializeAsync(); + foreach (var value in new object?[] { 1, "1", null, "", Array.Empty(), new[] { "a", "b" }, new[] { "b", "a" } }) + { + client.GetFlag("flag", new ReflagContext { Other = new Dictionary { ["value"] = value } }); + } + client.GetFlag("flag", ReflagContext.From(new { Other = new { b = 2, a = 1 } })); + client.GetFlag("flag", ReflagContext.From(new { Other = new { a = 1, b = 2 } })); + await client.FlushAsync(); + using var payload = JsonDocument.Parse(Assert.Single(transport.PostCalls).Body); + Assert.Equal(8, payload.RootElement.GetArrayLength()); + } + + [Fact] + public void Sorts_objects_recursively_including_inside_arrays_without_changing_types() + { + var context = new Dictionary + { + ["z"] = new object?[] { new { z = false, a = 1 }, null, "1" }, + ["a"] = new { z = "value", a = Array.Empty() }, + }; + Assert.Equal("{\"a\":{\"a\":[],\"z\":\"value\"},\"z\":[{\"a\":1,\"z\":false},null,\"1\"]}", CanonicalContextJson.Serialize(context)); + } + + [Fact] + public void Distinguishes_dotted_keys_from_nested_objects_and_array_indices() + { + var contexts = new[] + { + new Dictionary { ["a.b"] = "value" }, + new Dictionary { ["a"] = new { b = "value" } }, + new Dictionary { ["a"] = new[] { "value" } }, + new Dictionary { ["a"] = new Dictionary { ["0"] = "value" } }, + }; + Assert.Equal(contexts.Length, contexts.Select(CanonicalContextJson.Serialize).Distinct().Count()); + } +}