Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 46 additions & 0 deletions src/Reflag/Internal/CanonicalContextJson.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Text;
using System.Text.Json;

namespace Reflag.Internal;

internal static class CanonicalContextJson
{
public static string Serialize(IReadOnlyDictionary<string, object?> 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;
}
}
}
12 changes: 1 addition & 11 deletions src/Reflag/ReflagClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1042,17 +1042,7 @@ await _batchBuffer.AddAsync(

private static string BuildContextKey(IReadOnlyDictionary<string, object?> 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()
Expand Down
55 changes: 55 additions & 0 deletions tests/Reflag.Tests/CanonicalContextJsonTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>(), new[] { "a", "b" }, new[] { "b", "a" } })
{
client.GetFlag("flag", new ReflagContext { Other = new Dictionary<string, object?> { ["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<string, object?>
{
["z"] = new object?[] { new { z = false, a = 1 }, null, "1" },
["a"] = new { z = "value", a = Array.Empty<string>() },
};
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<string, object?> { ["a.b"] = "value" },
new Dictionary<string, object?> { ["a"] = new { b = "value" } },
new Dictionary<string, object?> { ["a"] = new[] { "value" } },
new Dictionary<string, object?> { ["a"] = new Dictionary<string, object?> { ["0"] = "value" } },
};
Assert.Equal(contexts.Length, contexts.Select(CanonicalContextJson.Serialize).Distinct().Count());
}
}
Loading