Skip to content

Commit 4614ff0

Browse files
authored
feat: add feature flag module with permissions-like architecture (#28)
* feat: add feature flag module with permissions-like architecture Add a complete FeatureFlags module that follows the permissions pattern: modules declare features via IModuleFeatures marker classes, the source generator auto-discovers them, and a runtime service resolves flag state with user/role override support. ## Framework (SimpleModule.Core) - IModuleFeatures marker interface for compile-time feature discovery - IFeatureFlagService for cross-module runtime checks - IFeatureFlagRegistry singleton with all known flag definitions - FeatureFlagRegistryBuilder merging discovered + configured definitions - EndpointFeatureFlagExtensions with .RequireFeature() endpoint filter - ConfigureFeatureFlags() added to IModule interface ## Source Generator - Feature class discovery mirroring permission class pattern - Diagnostics SM0045-SM0048 (sealed class, naming, duplicates, fields) - Auto-registration of feature classes and definitions in AddModules() ## Module Implementation - Contracts: IFeatureFlagContracts, DTOs, events, OverrideType enum - Entities using Entity<int> base class (auto timestamps, concurrency) - FeatureFlagService with cached resolution (user > role > global > default) - FeatureFlagSyncService for startup DB sync with table auto-creation - FeatureFlagMiddleware injecting flags into Inertia shared data - Full API endpoints (CRUD flags, overrides, check) - Admin UI (React) with toggle switches and override management - useFeatureFlag() React hook for frontend consumption - Permissions: FeatureFlags.View, FeatureFlags.Manage ## Tests - 14 unit + integration tests (service logic, endpoint auth, CRUD) - Playwright e2e smoke + flow tests - Test infrastructure integration (WebApplicationFactory) * fix: add FeatureFlags migration and harden middleware error handling - Add EF Core migration (AddFeatureFlagsModule) to create the FeatureFlags and FeatureFlagOverrides tables - Catch DbException in FeatureFlagMiddleware so a missing or unreachable database does not crash the entire request pipeline with a 500 — feature flags degrade gracefully instead - Update HostDbContext model snapshot to include FeatureFlags entities
1 parent beaf623 commit 4614ff0

67 files changed

Lines changed: 3785 additions & 23 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎SimpleModule.slnx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@
6969
<Project Path="modules/FileStorage/src/SimpleModule.FileStorage/SimpleModule.FileStorage.csproj" />
7070
<Project Path="modules/FileStorage/tests/SimpleModule.FileStorage.Tests/SimpleModule.FileStorage.Tests.csproj" />
7171
</Folder>
72+
<Folder Name="/modules/FeatureFlags/">
73+
<Project Path="modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/SimpleModule.FeatureFlags.Contracts.csproj" />
74+
<Project Path="modules/FeatureFlags/src/SimpleModule.FeatureFlags/SimpleModule.FeatureFlags.csproj" />
75+
<Project Path="modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/SimpleModule.FeatureFlags.Tests.csproj" />
76+
</Folder>
7277
<Folder Name="/modules/AuditLogs/">
7378
<Project Path="modules/AuditLogs/src/SimpleModule.AuditLogs.Contracts/SimpleModule.AuditLogs.Contracts.csproj" />
7479
<Project Path="modules/AuditLogs/src/SimpleModule.AuditLogs/SimpleModule.AuditLogs.csproj" />
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.Builder;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.DependencyInjection;
5+
6+
namespace SimpleModule.Core.FeatureFlags;
7+
8+
public static class EndpointFeatureFlagExtensions
9+
{
10+
public static TBuilder RequireFeature<TBuilder>(this TBuilder builder, string featureName)
11+
where TBuilder : IEndpointConventionBuilder
12+
{
13+
builder.AddEndpointFilter(
14+
async (context, next) =>
15+
{
16+
var featureFlagService =
17+
context.HttpContext.RequestServices.GetService<IFeatureFlagService>();
18+
19+
// If the service is not registered, allow the request (feature enabled by default)
20+
if (featureFlagService is null)
21+
{
22+
return await next(context);
23+
}
24+
25+
var userId = context.HttpContext.User.FindFirstValue(
26+
ClaimTypes.NameIdentifier
27+
);
28+
var roles = context
29+
.HttpContext.User.FindAll(ClaimTypes.Role)
30+
.Select(c => c.Value);
31+
32+
var isEnabled = await featureFlagService.IsEnabledAsync(
33+
featureName,
34+
userId,
35+
roles
36+
);
37+
38+
if (!isEnabled)
39+
{
40+
return Results.NotFound();
41+
}
42+
43+
return await next(context);
44+
}
45+
);
46+
47+
return builder;
48+
}
49+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace SimpleModule.Core.FeatureFlags;
2+
3+
public sealed class FeatureFlagBuilder : IFeatureFlagBuilder
4+
{
5+
private readonly List<FeatureFlagDefinition> _definitions = [];
6+
7+
public IFeatureFlagBuilder Add(FeatureFlagDefinition definition)
8+
{
9+
_definitions.Add(definition);
10+
return this;
11+
}
12+
13+
public List<FeatureFlagDefinition> ToList() => [.. _definitions];
14+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace SimpleModule.Core.FeatureFlags;
2+
3+
public class FeatureFlagDefinition
4+
{
5+
public string Name { get; set; } = string.Empty;
6+
public string Description { get; set; } = string.Empty;
7+
public bool DefaultEnabled { get; set; }
8+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
namespace SimpleModule.Core.FeatureFlags;
2+
3+
public sealed class FeatureFlagRegistry : IFeatureFlagRegistry
4+
{
5+
private readonly Dictionary<string, FeatureFlagDefinition> _definitions;
6+
7+
public FeatureFlagRegistry(
8+
HashSet<string> allNames,
9+
List<FeatureFlagDefinition> definitions
10+
)
11+
{
12+
// allNames parameter kept for API compatibility with generated code.
13+
// Build() already ensures definitions contains entries for all names.
14+
_ = allNames;
15+
_definitions = definitions.ToDictionary(d => d.Name);
16+
}
17+
18+
public FeatureFlagDefinition? GetDefinition(string name) =>
19+
_definitions.GetValueOrDefault(name);
20+
21+
public IReadOnlyList<FeatureFlagDefinition> GetAllDefinitions() =>
22+
_definitions.Values.ToList();
23+
24+
public bool IsKnownFeature(string name) => _definitions.ContainsKey(name);
25+
26+
public IReadOnlySet<string> GetAllFeatureNames() =>
27+
_definitions.Keys.ToHashSet(StringComparer.Ordinal);
28+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System.Reflection;
2+
3+
namespace SimpleModule.Core.FeatureFlags;
4+
5+
public sealed class FeatureFlagRegistryBuilder
6+
{
7+
private readonly HashSet<string> _allNames = [];
8+
private readonly Dictionary<string, FeatureFlagDefinition> _definitions = new();
9+
10+
public void AddFeatures<T>()
11+
where T : class
12+
{
13+
var fields = typeof(T)
14+
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
15+
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string));
16+
17+
foreach (var field in fields)
18+
{
19+
var value = (string)field.GetRawConstantValue()!;
20+
_allNames.Add(value);
21+
}
22+
}
23+
24+
public void AddDefinition(FeatureFlagDefinition definition)
25+
{
26+
_allNames.Add(definition.Name);
27+
_definitions[definition.Name] = definition;
28+
}
29+
30+
public void AddDefinitions(List<FeatureFlagDefinition> definitions)
31+
{
32+
foreach (var definition in definitions)
33+
{
34+
AddDefinition(definition);
35+
}
36+
}
37+
38+
public FeatureFlagRegistry Build()
39+
{
40+
// For names discovered via IModuleFeatures but without a definition,
41+
// create a default definition (disabled by default).
42+
var allDefinitions = new List<FeatureFlagDefinition>();
43+
foreach (var name in _allNames)
44+
{
45+
if (_definitions.TryGetValue(name, out var def))
46+
{
47+
allDefinitions.Add(def);
48+
}
49+
else
50+
{
51+
allDefinitions.Add(new FeatureFlagDefinition { Name = name });
52+
}
53+
}
54+
55+
return new FeatureFlagRegistry(_allNames, allDefinitions);
56+
}
57+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace SimpleModule.Core.FeatureFlags;
2+
3+
public interface IFeatureFlagBuilder
4+
{
5+
IFeatureFlagBuilder Add(FeatureFlagDefinition definition);
6+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace SimpleModule.Core.FeatureFlags;
2+
3+
public interface IFeatureFlagRegistry
4+
{
5+
FeatureFlagDefinition? GetDefinition(string name);
6+
IReadOnlyList<FeatureFlagDefinition> GetAllDefinitions();
7+
bool IsKnownFeature(string name);
8+
IReadOnlySet<string> GetAllFeatureNames();
9+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace SimpleModule.Core.FeatureFlags;
2+
3+
/// <summary>
4+
/// Minimal runtime interface for checking feature flag state.
5+
/// Lives in Core so any module can depend on it without referencing FeatureFlags.Contracts.
6+
/// </summary>
7+
public interface IFeatureFlagService
8+
{
9+
Task<bool> IsEnabledAsync(
10+
string flagName,
11+
string? userId = null,
12+
IEnumerable<string>? roles = null
13+
);
14+
15+
Task<Dictionary<string, bool>> GetAllEnabledAsync(
16+
string? userId = null,
17+
IEnumerable<string>? roles = null
18+
);
19+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace SimpleModule.Core.FeatureFlags;
2+
3+
/// <summary>
4+
/// Marker interface for feature flag classes. Implementations are auto-discovered
5+
/// by the source generator and registered with the feature flag registry.
6+
/// Feature classes must be sealed and contain only public const string fields.
7+
/// </summary>
8+
#pragma warning disable CA1040 // Avoid empty interfaces
9+
public interface IModuleFeatures;
10+
#pragma warning restore CA1040

0 commit comments

Comments
 (0)