diff --git a/Flagsmith.Client.Test/Fixtures.cs b/Flagsmith.Client.Test/Fixtures.cs index 5a3c5a6..e7f4f05 100644 --- a/Flagsmith.Client.Test/Fixtures.cs +++ b/Flagsmith.Client.Test/Fixtures.cs @@ -13,6 +13,7 @@ internal class Fixtures public static AnalyticsProcessorTest GetAnalyticalProcessorTest() => new(new HttpClient(), ApiKey, ApiUrl); public static JObject JsonObject = JObject.Parse(@"{ 'api_key': 'test_key', + 'name': 'Test Environment', 'project': { 'name': 'Test project', 'organisation': { @@ -56,7 +57,7 @@ internal class Fixtures 'multivariate_feature_state_values': [], 'feature_state_value': 'some-value', 'id': 1, - 'featurestate_uuid': '40eb539d-3713-4720-bbd4-829dbef10d51', + 'featurestate_uuid': '00000000-0000-0000-0000-000000000000', 'feature': { 'name': 'some_feature', 'type': 'STANDARD', @@ -64,6 +65,76 @@ internal class Fixtures }, 'segment_id': null, 'enabled': true + }, + { + 'feature_state_value': 'default_value', + 'django_id': 2, + 'featurestate_uuid': '11111111-1111-1111-1111-111111111111', + 'feature': { + 'name': 'mv_feature_with_ids', + 'type': 'MULTIVARIATE', + 'id': 2 + }, + 'segment_id': null, + 'enabled': true, + 'multivariate_feature_state_values': [ + { + 'id': 100, + 'multivariate_feature_option': { + 'id': 10, + 'value': 'variant_a' + }, + 'mv_fs_value_uuid': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'percentage_allocation': 30.0 + }, + { + 'id': 200, + 'multivariate_feature_option': { + 'id': 20, + 'value': 'variant_b' + }, + 'mv_fs_value_uuid': 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'percentage_allocation': 70.0 + } + ] + }, + { + 'feature_state_value': 'fallback_value', + 'django_id': 3, + 'featurestate_uuid': '22222222-2222-2222-2222-222222222222', + 'feature': { + 'name': 'mv_feature_without_ids', + 'type': 'MULTIVARIATE', + 'id': 3 + }, + 'segment_id': null, + 'enabled': false, + 'multivariate_feature_state_values': [ + { + 'multivariate_feature_option': { + 'id': 40, + 'value': 'option_y' + }, + 'mv_fs_value_uuid': 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + 'percentage_allocation': 50.0 + }, + { + 'multivariate_feature_option': { + 'id': 30, + 'value': 'option_x' + }, + 'mv_fs_value_uuid': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'percentage_allocation': 25.0 + }, + { + 'multivariate_feature_option': { + 'id': 50, + 'value': 'option_z' + }, + 'mv_fs_value_uuid': 'zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz', + 'percentage_allocation': 25.0 + } + ] } ], 'identity_overrides': [ diff --git a/Flagsmith.Client.Test/MappersTest.cs b/Flagsmith.Client.Test/MappersTest.cs new file mode 100644 index 0000000..4be7c3e --- /dev/null +++ b/Flagsmith.Client.Test/MappersTest.cs @@ -0,0 +1,132 @@ +using System.Linq; +using FlagsmithEngine; +using FlagsmithEngine.Segment; +using Xunit; + +namespace Flagsmith.FlagsmithClientTest +{ + public class MappersTest + { + [Fact] + public void MapEnvironmentDocumentToContext_ProducesEvaluationContext() + { + // Given + var environment = Fixtures.Environment; + + // When + var context = Mappers.MapEnvironmentDocumentToContext(environment); + + // Then + Assert.IsType>(context); + Assert.Equal("test_key", context.Environment.Key); + Assert.Equal("Test Environment", context.Environment.Name); + Assert.Null(context.Identity); + Assert.Equal(2, context.Segments.Count); + + // Verify API segment + Assert.True(context.Segments.ContainsKey("1")); + var apiSegment = context.Segments["1"]; + Assert.Equal("1", apiSegment.Key); + Assert.Equal("Test segment", apiSegment.Name); + Assert.Single(apiSegment.Rules); + Assert.Empty(apiSegment.Overrides); + Assert.Equal("api", apiSegment.Metadata.Source); + Assert.Equal(1, apiSegment.Metadata.Id); + + // Verify segment rule structure + Assert.Equal(TypeEnum.All, apiSegment.Rules[0].Type); + Assert.Empty(apiSegment.Rules[0].Conditions); + Assert.Single(apiSegment.Rules[0].Rules); + + Assert.Equal(TypeEnum.All, apiSegment.Rules[0].Rules[0].Type); + Assert.Single(apiSegment.Rules[0].Rules[0].Conditions); + Assert.Empty(apiSegment.Rules[0].Rules[0].Rules); + + Assert.Equal("foo", apiSegment.Rules[0].Rules[0].Conditions[0].Property); + Assert.Equal(Operator.Equal, apiSegment.Rules[0].Rules[0].Conditions[0].Operator); + Assert.Equal("bar", apiSegment.Rules[0].Rules[0].Conditions[0].Value.String); + + // Verify identity override segment + var overrideKey = "42d7556943d3c6f62b310e40f2252ac29203c20f37e9adffd8f12bd084a87b9d"; + Assert.True(context.Segments.ContainsKey(overrideKey)); + var overrideSegment = context.Segments[overrideKey]; + Assert.Equal("", overrideSegment.Key); + Assert.Equal("identity_overrides", overrideSegment.Name); + Assert.Single(overrideSegment.Rules); + Assert.Single(overrideSegment.Overrides); + + Assert.Equal(TypeEnum.All, overrideSegment.Rules[0].Type); + Assert.Single(overrideSegment.Rules[0].Conditions); + Assert.Empty(overrideSegment.Rules[0].Rules); + + Assert.Equal("$.identity.identifier", overrideSegment.Rules[0].Conditions[0].Property); + Assert.Equal(Operator.In, overrideSegment.Rules[0].Conditions[0].Operator); + Assert.Equal(new[] { "overridden-id" }, overrideSegment.Rules[0].Conditions[0].Value.StringArray); + + Assert.Equal("", overrideSegment.Overrides[0].Key); + Assert.Equal("some_feature", overrideSegment.Overrides[0].Name); + Assert.False(overrideSegment.Overrides[0].Enabled); + Assert.Equal("some-overridden-value", overrideSegment.Overrides[0].Value); + Assert.Equal(Constants.StrongestPriority, overrideSegment.Overrides[0].Priority); + Assert.Null(overrideSegment.Overrides[0].Variants); + Assert.Equal(1, overrideSegment.Overrides[0].Metadata.Id); + + // Verify features + Assert.Equal(3, context.Features.Count); + Assert.True(context.Features.ContainsKey("some_feature")); + var someFeature = context.Features["some_feature"]; + Assert.Equal("00000000-0000-0000-0000-000000000000", someFeature.Key); + Assert.Equal("some_feature", someFeature.Name); + Assert.True(someFeature.Enabled); + Assert.Equal("some-value", someFeature.Value); + Assert.Null(someFeature.Priority); + Assert.Empty(someFeature.Variants); + Assert.Equal(1, someFeature.Metadata.Id); + + // Verify multivariate feature with IDs - priority should be based on ID + Assert.True(context.Features.ContainsKey("mv_feature_with_ids")); + var mvFeatureWithIds = context.Features["mv_feature_with_ids"]; + Assert.Equal("2", mvFeatureWithIds.Key); + Assert.Equal("mv_feature_with_ids", mvFeatureWithIds.Name); + Assert.True(mvFeatureWithIds.Enabled); + Assert.Equal("default_value", mvFeatureWithIds.Value); + Assert.Null(mvFeatureWithIds.Priority); + Assert.Equal(2, mvFeatureWithIds.Variants.Length); + Assert.Equal(2, mvFeatureWithIds.Metadata.Id); + + // First variant: ID=100, should have priority 100 + Assert.Equal("variant_a", mvFeatureWithIds.Variants[0].Value); + Assert.Equal(30.0, mvFeatureWithIds.Variants[0].Weight); + Assert.Equal(100, mvFeatureWithIds.Variants[0].Priority); + + // Second variant: ID=200, should have priority 200 + Assert.Equal("variant_b", mvFeatureWithIds.Variants[1].Value); + Assert.Equal(70.0, mvFeatureWithIds.Variants[1].Weight); + Assert.Equal(200, mvFeatureWithIds.Variants[1].Priority); + + // Verify multivariate feature without IDs - priority should be based on UUID position + Assert.True(context.Features.ContainsKey("mv_feature_without_ids")); + var mvFeatureWithoutIds = context.Features["mv_feature_without_ids"]; + Assert.Equal("3", mvFeatureWithoutIds.Key); + Assert.Equal("mv_feature_without_ids", mvFeatureWithoutIds.Name); + Assert.False(mvFeatureWithoutIds.Enabled); + Assert.Equal("fallback_value", mvFeatureWithoutIds.Value); + Assert.Null(mvFeatureWithoutIds.Priority); + Assert.Equal(3, mvFeatureWithoutIds.Variants.Length); + Assert.Equal(3, mvFeatureWithoutIds.Metadata.Id); + + // Variants should be ordered by UUID alphabetically + Assert.Equal("option_y", mvFeatureWithoutIds.Variants[0].Value); + Assert.Equal(50.0, mvFeatureWithoutIds.Variants[0].Weight); + Assert.Equal(1, mvFeatureWithoutIds.Variants[0].Priority); // Second in sorted UUID order + + Assert.Equal("option_x", mvFeatureWithoutIds.Variants[1].Value); + Assert.Equal(25.0, mvFeatureWithoutIds.Variants[1].Weight); + Assert.Equal(0, mvFeatureWithoutIds.Variants[1].Priority); // First in sorted UUID order + + Assert.Equal("option_z", mvFeatureWithoutIds.Variants[2].Value); + Assert.Equal(25.0, mvFeatureWithoutIds.Variants[2].Weight); + Assert.Equal(2, mvFeatureWithoutIds.Variants[2].Priority); // Third in sorted UUID order + } + } +} diff --git a/Flagsmith.Client.Test/data/offline-environment.json b/Flagsmith.Client.Test/data/offline-environment.json index 04c68eb..2927c6d 100644 --- a/Flagsmith.Client.Test/data/offline-environment.json +++ b/Flagsmith.Client.Test/data/offline-environment.json @@ -1,5 +1,6 @@ { "api_key": "B62qaMZNwfiqT76p38ggrQ", + "name": "Test Environment", "project": { "name": "Test project", "organisation": { diff --git a/Flagsmith.Engine/Environment/Models/EnvironmentModel.cs b/Flagsmith.Engine/Environment/Models/EnvironmentModel.cs index 7b302c1..d5ac147 100644 --- a/Flagsmith.Engine/Environment/Models/EnvironmentModel.cs +++ b/Flagsmith.Engine/Environment/Models/EnvironmentModel.cs @@ -14,6 +14,10 @@ public class EnvironmentModel [JsonProperty(PropertyName = "api_key")] public string ApiKey { get; set; } + + [JsonProperty(PropertyName = "name")] + public string Name { get; set; } + [JsonProperty(PropertyName = "project")] public ProjectModel Project { get; set; } [JsonProperty(PropertyName = "feature_states")] diff --git a/Flagsmith.Engine/Segment/Constants.cs b/Flagsmith.Engine/Segment/Constants.cs index 5eee13c..e4f80fb 100644 --- a/Flagsmith.Engine/Segment/Constants.cs +++ b/Flagsmith.Engine/Segment/Constants.cs @@ -6,6 +6,7 @@ namespace FlagsmithEngine.Segment { public static class Constants { + public const double StrongestPriority = float.NegativeInfinity; public const double WeakestPriority = float.PositiveInfinity; public const string AllRule = "ALL"; diff --git a/Flagsmith.FlagsmithClient/IdentityWrapper.cs b/Flagsmith.FlagsmithClient/IdentityWrapper.cs index c86fc04..7a7efe5 100644 --- a/Flagsmith.FlagsmithClient/IdentityWrapper.cs +++ b/Flagsmith.FlagsmithClient/IdentityWrapper.cs @@ -19,11 +19,7 @@ public string CacheKey get { var combinedString = Identifier + JsonConvert.SerializeObject(Traits); - using (var sha256 = SHA256.Create()) - { - var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(combinedString)); - return BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); - } + return Utils.GetHashString(combinedString); } } diff --git a/Flagsmith.FlagsmithClient/Mappers.cs b/Flagsmith.FlagsmithClient/Mappers.cs new file mode 100644 index 0000000..c240465 --- /dev/null +++ b/Flagsmith.FlagsmithClient/Mappers.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using FlagsmithEngine; +using FlagsmithEngine.Environment.Models; +using FlagsmithEngine.Feature.Models; +using FlagsmithEngine.Identity.Models; +using FlagsmithEngine.Segment; +using FlagsmithEngine.Segment.Models; + +namespace Flagsmith +{ + /// + /// Utility class for transforming environment documents into evaluation contexts. + /// + public static class Mappers + { + /// + /// Property value for operators that do not use property (e.g., PERCENTAGE_SPLIT). + /// + private const string EmptyProperty = ""; + + /// + /// Key value for synthetic segments and identity override features. + /// + private const string UnusedKey = ""; + + /// + /// Parse the environment document into an EvaluationContext object + /// + public static EvaluationContext MapEnvironmentDocumentToContext( + EnvironmentModel environmentDocument) + { + var context = new EvaluationContext + { + Environment = new EnvironmentContext + { + Key = environmentDocument.ApiKey, + Name = environmentDocument.Name, + }, + Segments = new Dictionary>() + }; + + if (environmentDocument.Project.Segments != null) + { + foreach (var srcSegment in environmentDocument.Project.Segments) + { + var segment = new SegmentContext + { + Key = srcSegment.Id.ToString(), + Name = srcSegment.Name, + Rules = MapEnvironmentDocumentRulesToContextRules(srcSegment.Rules), + Metadata = new SegmentMetadata + { + Source = "api", + Id = srcSegment.Id, + }, + }; + + var overrides = MapEnvironmentDocumentFeatureStatesToFeatureContexts(srcSegment.FeatureStates); + segment.Overrides = overrides.Values.ToArray(); + + context.Segments[segment.Key] = segment; + } + } + + if (environmentDocument.IdentityOverrides != null) + { + var identityOverrideSegments = MapIdentityOverridesToSegments(environmentDocument.IdentityOverrides); + foreach (var kvp in identityOverrideSegments) + { + context.Segments[kvp.Key] = kvp.Value; + } + } + + context.Features = MapEnvironmentDocumentFeatureStatesToFeatureContexts(environmentDocument.FeatureStates); + + return context; + } + + /// + /// Attaches identity context into a new evaluation context based on context + /// + public static EvaluationContext MapContextAndIdentityToContext( + EvaluationContext context, + string identifier, + List traits) + { + var identity = new IdentityContext + { + Identifier = identifier, + Traits = traits?.ToDictionary(t => t.GetTraitKey(), t => t.GetTraitValue()) ?? new Dictionary(), + }; + + context = context.Clone(); + context.Identity = identity; + return context; + } + + private static SegmentRule[] MapEnvironmentDocumentRulesToContextRules(List srcRules) + { + return srcRules.Select(srcRule => new SegmentRule + { + Type = MapRuleType(srcRule.Type), + Conditions = (srcRule.Conditions ?? Enumerable.Empty()) + .Select(c => new Condition + { + Property = c.Property ?? EmptyProperty, + Operator = MapOperator(c.Operator), + Value = MapConditionValue(c.Value), + }) + .ToArray(), + Rules = srcRule.Rules != null + ? MapEnvironmentDocumentRulesToContextRules(srcRule.Rules) + : Array.Empty(), + }).ToArray(); + } + + private static Dictionary> MapEnvironmentDocumentFeatureStatesToFeatureContexts( + List featureStates) + { + var featureContexts = new Dictionary>(); + + foreach (var featureState in featureStates) + { + var feature = new FeatureContext + { + Key = (featureState.DjangoId?.ToString() ?? featureState.FeatureStateUUID), + Name = featureState.Feature.Name, + Enabled = featureState.Enabled, + Value = featureState.Value, + Priority = featureState.FeatureSegment?.Priority, + Metadata = new FeatureMetadata { Id = featureState.Feature.Id }, + Variants = Array.Empty(), + }; + + if (featureState.MultivariateFeatureStateValues?.Count > 0) + { + var sortedUUIDs = featureState.MultivariateFeatureStateValues + .Select(mv => mv.MvFsValueUUID) + .OrderBy(uuid => uuid) + .ToArray(); + + feature.Variants = featureState.MultivariateFeatureStateValues + .Select(mv => new FeatureValue + { + Value = mv.MultivariateFeatureOption.Value, + Weight = mv.PercentageAllocation, + Priority = mv.Id != 0 ? mv.Id : Array.IndexOf(sortedUUIDs, mv.MvFsValueUUID), + }) + .ToArray(); + } + + featureContexts[feature.Name] = feature; + } + + return featureContexts; + } + + private static Dictionary> MapIdentityOverridesToSegments( + List identityOverrides) + { + var featureIDsByName = new Dictionary(); + var featuresToIdentifiers = new Dictionary>(); + var overridesBySerializedJsonKey = new Dictionary>(); + + foreach (var identityOverride in identityOverrides) + { + var identityFeatures = (identityOverride.IdentityFeatures ?? new List()) + .OrderBy(f => f.Feature.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (identityFeatures.Length == 0) + { + continue; + } + + var overridesKey = new List<(string Name, bool Enabled, object Value)>(); + foreach (var featureState in identityFeatures) + { + featureIDsByName[featureState.Feature.Name] = featureState.Feature.Id; + overridesKey.Add(( + featureState.Feature.Name, + featureState.Enabled, + featureState.Value + )); + } + + var serializedOverridesKey = JsonConvert.SerializeObject(overridesKey); + + if (!featuresToIdentifiers.ContainsKey(serializedOverridesKey)) + { + featuresToIdentifiers[serializedOverridesKey] = new List(); + overridesBySerializedJsonKey[serializedOverridesKey] = overridesKey; + } + featuresToIdentifiers[serializedOverridesKey].Add(identityOverride.Identifier); + } + + // For each unique set of feature overrides, create a virtual segment. + // These segments act as synthetic segments that match specific identities via an IN condition, + // and apply the shared feature overrides to those identities with strongest priority. + var segments = new Dictionary>(); + + foreach (var kvp in featuresToIdentifiers) + { + var serializedOverridesKey = kvp.Key; + var identifiers = kvp.Value; + + var segment = new SegmentContext + { + Key = UnusedKey, + Name = "identity_overrides", + Metadata = new SegmentMetadata + { + Source = "identity_override", + Id = null, + }, + }; + + var identifiersCondition = new Condition + { + Property = "$.identity.identifier", + Operator = Operator.In, + Value = new ConditionValueUnion { StringArray = identifiers.ToArray() }, + }; + + var identifiersRule = new SegmentRule + { + Type = TypeEnum.All, + Conditions = new[] { identifiersCondition }, + Rules = Array.Empty(), + }; + + segment.Rules = new[] { identifiersRule }; + + var overridesKey = overridesBySerializedJsonKey[serializedOverridesKey]; + + segment.Overrides = overridesKey.Select(overrideKey => new FeatureContext + { + Key = UnusedKey, + Name = overrideKey.Name, + Enabled = overrideKey.Enabled, + Value = overrideKey.Value, + Priority = Constants.StrongestPriority, + Metadata = new FeatureMetadata { Id = featureIDsByName[overrideKey.Name] }, + }).ToArray(); + + var segmentKey = Utils.GetHashString(serializedOverridesKey); + segments[segmentKey] = segment; + } + + return segments; + } + + private static TypeEnum MapRuleType(string type) + { + return type switch + { + Constants.AllRule => TypeEnum.All, + Constants.AnyRule => TypeEnum.Any, + Constants.NoneRule => TypeEnum.None, + _ => throw new ArgumentException($"Unknown rule type: {type}"), + }; + } + + private static Operator MapOperator(string operatorString) + { + return operatorString switch + { + Constants.Equal => Operator.Equal, + Constants.NotEqual => Operator.NotEqual, + Constants.GreaterThan => Operator.GreaterThan, + Constants.GreaterThanInclusive => Operator.GreaterThanInclusive, + Constants.LessThan => Operator.LessThan, + Constants.LessThanInclusive => Operator.LessThanInclusive, + Constants.Contains => Operator.Contains, + Constants.NotContains => Operator.NotContains, + Constants.In => Operator.In, + Constants.Regex => Operator.Regex, + Constants.Modulo => Operator.Modulo, + Constants.IsSet => Operator.IsSet, + Constants.IsNotSet => Operator.IsNotSet, + Constants.PercentageSplit => Operator.PercentageSplit, + _ => throw new ArgumentException($"Unknown operator: {operatorString}"), + }; + } + + private static ConditionValueUnion MapConditionValue(string value) + { + return new ConditionValueUnion { String = value ?? "" }; + } + } +} diff --git a/Flagsmith.FlagsmithClient/Metadata.cs b/Flagsmith.FlagsmithClient/Metadata.cs new file mode 100644 index 0000000..98b6d43 --- /dev/null +++ b/Flagsmith.FlagsmithClient/Metadata.cs @@ -0,0 +1,29 @@ +namespace Flagsmith +{ + /// + /// Metadata associated with a feature in the evaluation context. + /// + public class FeatureMetadata + { + /// + /// The feature identifier from the API. + /// + public int Id { get; set; } + } + + /// + /// Metadata associated with a segment in the evaluation context. + /// + public class SegmentMetadata + { + /// + /// The segment identifier from the API. Null for synthetic segments. + /// + public int? Id { get; set; } + + /// + /// The source of the segment: "api" for API-defined segments, "identity_override" for synthetic segments. + /// + public string? Source { get; set; } + } +} diff --git a/Flagsmith.FlagsmithClient/Utils.cs b/Flagsmith.FlagsmithClient/Utils.cs new file mode 100644 index 0000000..8b56de3 --- /dev/null +++ b/Flagsmith.FlagsmithClient/Utils.cs @@ -0,0 +1,21 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Flagsmith +{ + internal static class Utils + { + /// + /// Computes SHA256 hash of the input text and returns it as a lowercase hex string. + /// + internal static string GetHashString(string text) + { + using (var sha256 = SHA256.Create()) + { + byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(text)); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } + } + } +}