Adding Flags API
This commit is contained in:
139
src/api/MicCheck.Api/Features/FeatureEvaluationService.cs
Normal file
139
src/api/MicCheck.Api/Features/FeatureEvaluationService.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Segments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class FeatureEvaluationService(
|
||||
MicCheckDbContext db,
|
||||
SegmentEvaluator segmentEvaluator,
|
||||
FlagCache flagCache)
|
||||
{
|
||||
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
|
||||
int environmentId, CancellationToken ct = default)
|
||||
{
|
||||
var cached = flagCache.Get(environmentId);
|
||||
if (cached is not null)
|
||||
return cached;
|
||||
|
||||
var results = await (
|
||||
from fs in db.FeatureStates
|
||||
join f in db.Features on fs.FeatureId equals f.Id
|
||||
where fs.EnvironmentId == environmentId
|
||||
&& fs.IdentityId == null
|
||||
&& fs.FeatureSegmentId == null
|
||||
select new FeatureStateResult
|
||||
{
|
||||
Feature = f,
|
||||
Enabled = fs.Enabled,
|
||||
Value = fs.Value
|
||||
}
|
||||
).ToListAsync(ct);
|
||||
|
||||
flagCache.Set(environmentId, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForIdentityAsync(
|
||||
int environmentId,
|
||||
string identifier,
|
||||
IReadOnlyList<TraitInput>? traits = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var environment = await db.Environments.FindAsync([environmentId], ct);
|
||||
if (environment is null)
|
||||
return [];
|
||||
|
||||
var identity = await new IdentityResolutionService(db)
|
||||
.ResolveAsync(environmentId, identifier, traits, ct);
|
||||
|
||||
var features = await db.Features
|
||||
.Where(f => f.ProjectId == environment.ProjectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var featureIds = features.Select(f => f.Id).ToList();
|
||||
|
||||
var allFeatureStates = await db.FeatureStates
|
||||
.Where(fs => fs.EnvironmentId == environmentId && featureIds.Contains(fs.FeatureId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var featureSegments = await db.FeatureSegments
|
||||
.Where(fsg => fsg.EnvironmentId == environmentId && featureIds.Contains(fsg.FeatureId))
|
||||
.OrderBy(fsg => fsg.Priority)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var segmentIds = featureSegments.Select(fsg => fsg.SegmentId).Distinct().ToList();
|
||||
var segments = await db.Segments
|
||||
.Include(s => s.Rules)
|
||||
.ThenInclude(r => r.Conditions)
|
||||
.Include(s => s.Rules)
|
||||
.ThenInclude(r => r.ChildRules)
|
||||
.ThenInclude(cr => cr.Conditions)
|
||||
.Where(s => segmentIds.Contains(s.Id))
|
||||
.ToDictionaryAsync(s => s.Id, ct);
|
||||
|
||||
var identityTraits = identity.Traits.ToList();
|
||||
var results = new List<FeatureStateResult>();
|
||||
|
||||
foreach (var feature in features)
|
||||
{
|
||||
// 1. Identity-level override
|
||||
var identityOverride = allFeatureStates.FirstOrDefault(
|
||||
fs => fs.FeatureId == feature.Id && fs.IdentityId == identity.Id);
|
||||
if (identityOverride is not null)
|
||||
{
|
||||
results.Add(new FeatureStateResult
|
||||
{
|
||||
Feature = feature,
|
||||
Enabled = identityOverride.Enabled,
|
||||
Value = identityOverride.Value
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Segment overrides (highest priority segment that matches)
|
||||
var segmentOverrideFound = false;
|
||||
foreach (var fsg in featureSegments.Where(fsg => fsg.FeatureId == feature.Id))
|
||||
{
|
||||
if (!segments.TryGetValue(fsg.SegmentId, out var segment))
|
||||
continue;
|
||||
if (!segmentEvaluator.Evaluate(segment, identityTraits, identity.Identifier))
|
||||
continue;
|
||||
|
||||
var segmentState = allFeatureStates.FirstOrDefault(
|
||||
fs => fs.FeatureId == feature.Id && fs.FeatureSegmentId == fsg.Id);
|
||||
if (segmentState is null)
|
||||
continue;
|
||||
|
||||
results.Add(new FeatureStateResult
|
||||
{
|
||||
Feature = feature,
|
||||
Enabled = segmentState.Enabled,
|
||||
Value = segmentState.Value
|
||||
});
|
||||
segmentOverrideFound = true;
|
||||
break;
|
||||
}
|
||||
if (segmentOverrideFound)
|
||||
continue;
|
||||
|
||||
// 3. Environment default
|
||||
var envDefault = allFeatureStates.FirstOrDefault(
|
||||
fs => fs.FeatureId == feature.Id
|
||||
&& fs.IdentityId == null
|
||||
&& fs.FeatureSegmentId == null);
|
||||
if (envDefault is not null)
|
||||
{
|
||||
results.Add(new FeatureStateResult
|
||||
{
|
||||
Feature = feature,
|
||||
Enabled = envDefault.Enabled,
|
||||
Value = envDefault.Value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
7
src/api/MicCheck.Api/Features/FeatureSummaryResponse.cs
Normal file
7
src/api/MicCheck.Api/Features/FeatureSummaryResponse.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record FeatureSummaryResponse(int Id, string Name, string Type)
|
||||
{
|
||||
public static FeatureSummaryResponse From(Feature feature) =>
|
||||
new(feature.Id, feature.Name, feature.Type.ToString().ToUpperInvariant());
|
||||
}
|
||||
22
src/api/MicCheck.Api/Features/FlagCache.cs
Normal file
22
src/api/MicCheck.Api/Features/FlagCache.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class FlagCache(IMemoryCache cache)
|
||||
{
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
|
||||
|
||||
public IReadOnlyList<FeatureStateResult>? Get(int environmentId)
|
||||
{
|
||||
cache.TryGetValue(CacheKey(environmentId), out IReadOnlyList<FeatureStateResult>? flags);
|
||||
return flags;
|
||||
}
|
||||
|
||||
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags)
|
||||
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
|
||||
|
||||
public void Invalidate(int environmentId)
|
||||
=> cache.Remove(CacheKey(environmentId));
|
||||
|
||||
private static string CacheKey(int environmentId) => $"flags:{environmentId}";
|
||||
}
|
||||
14
src/api/MicCheck.Api/Features/FlagResponse.cs
Normal file
14
src/api/MicCheck.Api/Features/FlagResponse.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record FlagResponse(
|
||||
int Id,
|
||||
FeatureSummaryResponse Feature,
|
||||
bool Enabled,
|
||||
string? FeatureStateValue)
|
||||
{
|
||||
public static FlagResponse From(FeatureStateResult result) => new(
|
||||
result.Feature.Id,
|
||||
FeatureSummaryResponse.From(result.Feature),
|
||||
result.Enabled,
|
||||
result.Value);
|
||||
}
|
||||
19
src/api/MicCheck.Api/Features/FlagsController.cs
Normal file
19
src/api/MicCheck.Api/Features/FlagsController.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/flags")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class FlagsController(FeatureEvaluationService featureEvaluationService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<FlagResponse>>> GetAll(CancellationToken ct)
|
||||
{
|
||||
var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value);
|
||||
var results = await featureEvaluationService.EvaluateForEnvironmentAsync(environmentId, ct);
|
||||
return Ok(results.Select(FlagResponse.From).ToList());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user