Skip to content
Open
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
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "Flagsmith.EngineTest/EngineTestData"]
path = Flagsmith.EngineTest/EngineTestData
url = git@github.com:Flagsmith/engine-test-data.git
branch = v3.10.0
branch = v3.11.0
1 change: 1 addition & 0 deletions Flagsmith.Client.Test/Fixtures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ internal class Fixtures
'id': 100,
'multivariate_feature_option': {
'id': 10,
'key': 'variant_a_key',
'value': 'variant_a'
},
'mv_fs_value_uuid': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
Expand Down
86 changes: 86 additions & 0 deletions Flagsmith.Client.Test/FlagsmithTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,92 @@ public async Task TestGetIdentityFlagsUsesLocalIdentityOverridesWhenAvailable()
Assert.False(flag.Enabled);
Assert.Equal("some-overridden-value", flag.Value);
}
[Theory]
[InlineData("premium", true, "new-checkout")]
[InlineData("free", false, "old-checkout")]
public async Task TestGetIdentityFlagsServesFlagDependingOnAnotherFlagWhenLocallyEvaluated(string plan, bool expectedEnabled, string expectedValue)
{
// Given
var environmentDocument = @"{
'api_key': 'test_key',
'name': 'Test Environment',
'project': {
'name': 'Test project',
'organisation': {'feature_analytics': false, 'name': 'Test Org', 'id': 1, 'persist_trait_data': true, 'stop_serving_flags': false},
'id': 1,
'hide_disabled_flags': false,
'segments': [
{
'id': 1,
'name': 'Premium plan',
'rules': [{'type': 'ALL', 'rules': [], 'conditions': [{'operator': 'EQUAL', 'property_': 'plan', 'value': 'premium'}]}],
'feature_states': [
{
'django_id': 3,
'feature': {'name': 'beta_access', 'type': 'STANDARD', 'id': 1},
'feature_state_value': null,
'enabled': true,
'feature_segment': {'priority': 0},
'multivariate_feature_state_values': []
}
]
},
{
'id': 2,
'name': 'Beta access',
'rules': [{'type': 'ALL', 'rules': [], 'conditions': [{'operator': 'EQUAL', 'property_': '$.flags.beta_access.enabled', 'value': 'true'}]}],
'feature_states': [
{
'django_id': 4,
'feature': {'name': 'checkout', 'type': 'STANDARD', 'id': 2},
'feature_state_value': 'new-checkout',
'enabled': true,
'feature_segment': {'priority': 1},
'multivariate_feature_state_values': []
}
]
}
]
},
'id': 1,
'feature_states': [
{
'django_id': 1,
'feature': {'name': 'beta_access', 'type': 'STANDARD', 'id': 1},
'feature_state_value': null,
'enabled': false,
'multivariate_feature_state_values': []
},
{
'django_id': 2,
'feature': {'name': 'checkout', 'type': 'STANDARD', 'id': 2},
'feature_state_value': 'old-checkout',
'enabled': false,
'multivariate_feature_state_values': []
}
],
'identity_overrides': []
}";
var mockHttpClient = HttpMocker.MockHttpResponse(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(environmentDocument)
});
var flagsmithClientTest = new FlagsmithClient(new FlagsmithConfiguration
{
EnvironmentKey = Fixtures.ApiKey,
HttpClient = mockHttpClient.Object,
EnableLocalEvaluation = true
});

// When
var flags = await flagsmithClientTest.GetIdentityFlags("identifier", new List<ITrait> { new Trait("plan", plan) });

// Then
var flag = await flags.GetFlag("checkout");
Assert.Equal(expectedEnabled, flag.Enabled);
Assert.Equal(expectedValue, flag.Value);
}
[Fact]
public async Task TestRequestConnectionErrorRaisesFlagsmithApiError()
{
Expand Down
2 changes: 2 additions & 0 deletions Flagsmith.Client.Test/MappersTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,13 @@ public void MapEnvironmentDocumentToContext_ProducesEvaluationContext()
Assert.Equal(2, mvFeatureWithIds.Metadata.Id);

// First variant: ID=100, should have priority 100
Assert.Equal("variant_a_key", mvFeatureWithIds.Variants[0].Key);
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.Null(mvFeatureWithIds.Variants[1].Key);
Assert.Equal("variant_b", mvFeatureWithIds.Variants[1].Value);
Assert.Equal(70.0, mvFeatureWithIds.Variants[1].Weight);
Assert.Equal(200, mvFeatureWithIds.Variants[1].Priority);
Expand Down
219 changes: 219 additions & 0 deletions Flagsmith.Engine/Engine.FlagDependencies.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
using System.Collections.Generic;
using System.Text;
using Newtonsoft.Json.Linq;

namespace FlagsmithEngine
{
public partial class Engine
{
private const string FlagsPathPrefix = "$.flags";

/// <summary>
/// The `$.flags` mapping read by segment conditions, resolving each flag the first time a condition reads it.
/// </summary>
private sealed class FlagDependencies<SegmentMetadataT, FeatureMetadataT>
{
private readonly EvaluationContext<SegmentMetadataT, FeatureMetadataT> _context;
// Allocated once a condition first reads a flag, sparing evaluations without Flag Dependency
private Dictionary<string, FlagResult<FeatureMetadataT>> _resolved;
private HashSet<string> _cyclic;
private List<string> _resolving;
private Dictionary<string, bool> _segmentMatches;
private Dictionary<string, List<string>> _segmentKeysByFeatureName;

public FlagDependencies(EvaluationContext<SegmentMetadataT, FeatureMetadataT> context)
{
_context = context;
}

/// <summary>
/// Whether any segment condition has read a flag.
/// </summary>
public bool Used => _resolved != null;

/// <summary>
/// Incremented whenever a dependency cycle is cut. A segment whose evaluation increments it read a flag that could not be resolved.
/// </summary>
private int CycleHits { get; set; }

/// <summary>
/// Flags resolved for segment conditions, mapped by feature name. Flags in a dependency cycle are never resolved.
/// </summary>
public IReadOnlyDictionary<string, FlagResult<FeatureMetadataT>> Resolved => _resolved;

/// <summary>
/// Names of the features found to be in a dependency cycle.
/// </summary>
public IEnumerable<string> Cyclic => _cyclic;

/// <summary>
/// Get the flag result for a feature, or null when the feature is absent from the context or in a dependency cycle.
/// </summary>
public FlagResult<FeatureMetadataT> Get(string featureName)
{
if (!Used)
{
_resolved = new Dictionary<string, FlagResult<FeatureMetadataT>>();
_cyclic = new HashSet<string>();
_resolving = new List<string>();
_segmentMatches = new Dictionary<string, bool>();
}
if (!_resolved.TryGetValue(featureName, out var flag))
{
Resolve(featureName);
_resolved.TryGetValue(featureName, out flag);
}
return flag;
}

private void Resolve(string featureName)
{
var resolvingIndex = _resolving.IndexOf(featureName);
if (resolvingIndex >= 0)
{
_cyclic.UnionWith(_resolving.GetRange(resolvingIndex, _resolving.Count - resolvingIndex));
CycleHits++;
return;
}

FeatureContext<FeatureMetadataT> featureContext = null;
if (!(_context.Features?.TryGetValue(featureName, out featureContext) ?? false))
return;

_resolving.Add(featureName);
var segmentOverride = GetSegmentOverride(featureName);
_resolving.RemoveAt(_resolving.Count - 1);

if (_cyclic.Contains(featureName))
return;

_resolved[featureName] = segmentOverride is null
? GetFlagResult(_context, featureContext, "DEFAULT")
: GetFlagResult(_context, segmentOverride.FeatureContext, GetTargetingMatchReason(segmentOverride.SegmentName));
}

private SegmentOverride<FeatureMetadataT> GetSegmentOverride(string featureName)
{
SegmentOverride<FeatureMetadataT> segmentOverride = null;

if (!GetSegmentKeysByFeatureName().TryGetValue(featureName, out var segmentKeys))
return segmentOverride;

foreach (var segmentKey in segmentKeys)
{
if (!MatchesSegment(segmentKey))
continue;
var segmentContext = _context.Segments[segmentKey];
foreach (var overrideFeatureContext in segmentContext.Overrides)
{
if (overrideFeatureContext.Name != featureName || !WinsOver(overrideFeatureContext, segmentOverride?.FeatureContext))
continue;
segmentOverride = new SegmentOverride<FeatureMetadataT>
{
FeatureContext = overrideFeatureContext,
SegmentName = segmentContext.Name
};
}
}

return segmentOverride;
}

/// <summary>
/// Whether the context matches a segment, evaluating each segment once. Returns false when the verdict is
/// unsound, having read a flag left unresolved by a dependency cycle.
/// </summary>
public bool TryMatchSegment(string segmentKey, out bool matches)
{
if (Used && _segmentMatches.TryGetValue(segmentKey, out matches))
return true;

var cycleHits = CycleHits;
matches = IsContextInSegment(_context, _context.Segments[segmentKey], this);

if (CycleHits != cycleHits)
return false;

if (Used)
_segmentMatches[segmentKey] = matches;
return true;
}

private bool MatchesSegment(string segmentKey) => TryMatchSegment(segmentKey, out var matches) && matches;

private Dictionary<string, List<string>> GetSegmentKeysByFeatureName()
{
if (_segmentKeysByFeatureName != null)
return _segmentKeysByFeatureName;

_segmentKeysByFeatureName = new Dictionary<string, List<string>>();
foreach (var segmentItem in _context.Segments ?? new Dictionary<string, SegmentContext<SegmentMetadataT, FeatureMetadataT>>())
{
if (segmentItem.Value.Overrides is null)
continue;
foreach (var overrideFeatureContext in segmentItem.Value.Overrides)
{
if (!_segmentKeysByFeatureName.TryGetValue(overrideFeatureContext.Name, out var segmentKeys))
_segmentKeysByFeatureName[overrideFeatureContext.Name] = segmentKeys = new List<string>();
if (!segmentKeys.Contains(segmentItem.Key))
segmentKeys.Add(segmentItem.Key);
}
}
return _segmentKeysByFeatureName;
}
}

/// <summary>
/// Get the feature name a JSONPath property reads from `$.flags`, e.g. `my_feature` from `$.flags.my_feature.enabled` or `$.flags['my_feature'].enabled`.
/// </summary>
private static bool TryGetFlagDependencyName(string property, out string featureName)
{
featureName = null;
if (!property.StartsWith(FlagsPathPrefix) || property.Length <= FlagsPathPrefix.Length)
return false;

var position = FlagsPathPrefix.Length;
switch (property[position])
{
case '.':
var nameStart = position + 1;
var nameEnd = property.IndexOfAny(new[] { '.', '[' }, nameStart);
if (nameEnd < 0)
nameEnd = property.Length;
featureName = property.Substring(nameStart, nameEnd - nameStart);
return featureName.Length > 0 && featureName != "*";
case '[':
if (property.Length <= position + 1)
return false;
var quote = property[position + 1];
if (quote != '\'' && quote != '"')
return false;
var name = new StringBuilder();
for (var index = position + 2; index < property.Length; index++)
{
var character = property[index];
if (character == '\\' && index + 1 < property.Length)
name.Append(property[++index]);
else if (character == quote)
{
featureName = name.ToString();
return true;
}
else
name.Append(character);
}
return false;
default:
return false;
}
}

private static JObject GetFlagsRoot<FeatureMetadataT>(string featureName, FlagResult<FeatureMetadataT> flag)
{
var flags = new JObject();
if (flag != null)
flags[featureName] = JToken.FromObject(flag);
return new JObject { ["flags"] = flags };
}
}
}
Loading
Loading