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
5 changes: 5 additions & 0 deletions SimpleModule.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
<Project Path="modules/FileStorage/src/SimpleModule.FileStorage/SimpleModule.FileStorage.csproj" />
<Project Path="modules/FileStorage/tests/SimpleModule.FileStorage.Tests/SimpleModule.FileStorage.Tests.csproj" />
</Folder>
<Folder Name="/modules/FeatureFlags/">
<Project Path="modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts/SimpleModule.FeatureFlags.Contracts.csproj" />
<Project Path="modules/FeatureFlags/src/SimpleModule.FeatureFlags/SimpleModule.FeatureFlags.csproj" />
<Project Path="modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/SimpleModule.FeatureFlags.Tests.csproj" />
</Folder>
<Folder Name="/modules/AuditLogs/">
<Project Path="modules/AuditLogs/src/SimpleModule.AuditLogs.Contracts/SimpleModule.AuditLogs.Contracts.csproj" />
<Project Path="modules/AuditLogs/src/SimpleModule.AuditLogs/SimpleModule.AuditLogs.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace SimpleModule.Core.FeatureFlags;

public static class EndpointFeatureFlagExtensions
{
public static TBuilder RequireFeature<TBuilder>(this TBuilder builder, string featureName)
where TBuilder : IEndpointConventionBuilder
{
builder.AddEndpointFilter(
async (context, next) =>
{
var featureFlagService =
context.HttpContext.RequestServices.GetService<IFeatureFlagService>();

// If the service is not registered, allow the request (feature enabled by default)
if (featureFlagService is null)
{
return await next(context);
}

var userId = context.HttpContext.User.FindFirstValue(
ClaimTypes.NameIdentifier
);
var roles = context
.HttpContext.User.FindAll(ClaimTypes.Role)
.Select(c => c.Value);

var isEnabled = await featureFlagService.IsEnabledAsync(
featureName,
userId,
roles
);

if (!isEnabled)
{
return Results.NotFound();
}

return await next(context);
}
);

return builder;
}
}
14 changes: 14 additions & 0 deletions framework/SimpleModule.Core/FeatureFlags/FeatureFlagBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace SimpleModule.Core.FeatureFlags;

public sealed class FeatureFlagBuilder : IFeatureFlagBuilder
{
private readonly List<FeatureFlagDefinition> _definitions = [];

public IFeatureFlagBuilder Add(FeatureFlagDefinition definition)
{
_definitions.Add(definition);
return this;
}

public List<FeatureFlagDefinition> ToList() => [.. _definitions];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace SimpleModule.Core.FeatureFlags;

public class FeatureFlagDefinition
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public bool DefaultEnabled { get; set; }
}
28 changes: 28 additions & 0 deletions framework/SimpleModule.Core/FeatureFlags/FeatureFlagRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace SimpleModule.Core.FeatureFlags;

public sealed class FeatureFlagRegistry : IFeatureFlagRegistry
{
private readonly Dictionary<string, FeatureFlagDefinition> _definitions;

public FeatureFlagRegistry(
HashSet<string> allNames,
List<FeatureFlagDefinition> definitions
)
{
// allNames parameter kept for API compatibility with generated code.
// Build() already ensures definitions contains entries for all names.
_ = allNames;
_definitions = definitions.ToDictionary(d => d.Name);
}

public FeatureFlagDefinition? GetDefinition(string name) =>
_definitions.GetValueOrDefault(name);

public IReadOnlyList<FeatureFlagDefinition> GetAllDefinitions() =>
_definitions.Values.ToList();

public bool IsKnownFeature(string name) => _definitions.ContainsKey(name);

public IReadOnlySet<string> GetAllFeatureNames() =>
_definitions.Keys.ToHashSet(StringComparer.Ordinal);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Reflection;

namespace SimpleModule.Core.FeatureFlags;

public sealed class FeatureFlagRegistryBuilder
{
private readonly HashSet<string> _allNames = [];
private readonly Dictionary<string, FeatureFlagDefinition> _definitions = new();

public void AddFeatures<T>()
where T : class
{
var fields = typeof(T)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string));

foreach (var field in fields)
{
var value = (string)field.GetRawConstantValue()!;
_allNames.Add(value);
}
}

public void AddDefinition(FeatureFlagDefinition definition)
{
_allNames.Add(definition.Name);
_definitions[definition.Name] = definition;
}

public void AddDefinitions(List<FeatureFlagDefinition> definitions)
{
foreach (var definition in definitions)
{
AddDefinition(definition);
}
}

public FeatureFlagRegistry Build()
{
// For names discovered via IModuleFeatures but without a definition,
// create a default definition (disabled by default).
var allDefinitions = new List<FeatureFlagDefinition>();
foreach (var name in _allNames)
{
if (_definitions.TryGetValue(name, out var def))
{
allDefinitions.Add(def);
}
else
{
allDefinitions.Add(new FeatureFlagDefinition { Name = name });
}
}

return new FeatureFlagRegistry(_allNames, allDefinitions);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace SimpleModule.Core.FeatureFlags;

public interface IFeatureFlagBuilder
{
IFeatureFlagBuilder Add(FeatureFlagDefinition definition);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace SimpleModule.Core.FeatureFlags;

public interface IFeatureFlagRegistry
{
FeatureFlagDefinition? GetDefinition(string name);
IReadOnlyList<FeatureFlagDefinition> GetAllDefinitions();
bool IsKnownFeature(string name);
IReadOnlySet<string> GetAllFeatureNames();
}
19 changes: 19 additions & 0 deletions framework/SimpleModule.Core/FeatureFlags/IFeatureFlagService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace SimpleModule.Core.FeatureFlags;

/// <summary>
/// Minimal runtime interface for checking feature flag state.
/// Lives in Core so any module can depend on it without referencing FeatureFlags.Contracts.
/// </summary>
public interface IFeatureFlagService
{
Task<bool> IsEnabledAsync(
string flagName,
string? userId = null,
IEnumerable<string>? roles = null
);

Task<Dictionary<string, bool>> GetAllEnabledAsync(
string? userId = null,
IEnumerable<string>? roles = null
);
}
10 changes: 10 additions & 0 deletions framework/SimpleModule.Core/FeatureFlags/IModuleFeatures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace SimpleModule.Core.FeatureFlags;

/// <summary>
/// Marker interface for feature flag classes. Implementations are auto-discovered
/// by the source generator and registered with the feature flag registry.
/// Feature classes must be sealed and contain only public const string fields.
/// </summary>
#pragma warning disable CA1040 // Avoid empty interfaces
public interface IModuleFeatures;
#pragma warning restore CA1040
2 changes: 2 additions & 0 deletions framework/SimpleModule.Core/IModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SimpleModule.Core.Authorization;
using SimpleModule.Core.FeatureFlags;
using SimpleModule.Core.Menu;
using SimpleModule.Core.Settings;

Expand All @@ -16,6 +17,7 @@ virtual void ConfigureMiddleware(IApplicationBuilder app) { }
virtual void ConfigureMenu(IMenuBuilder menus) { }
virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { }
virtual void ConfigureSettings(ISettingsBuilder settings) { }
virtual void ConfigureFeatureFlags(IFeatureFlagBuilder builder) { }

/// <summary>
/// Called once during application startup after all services are registered.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ SM0041 | SimpleModule.Generator | Warning | View page name does not match module
SM0042 | SimpleModule.Generator | Error | Module has view endpoints but no ViewPrefix
SM0043 | SimpleModule.Generator | Warning | Module does not override any IModule methods
SM0044 | SimpleModule.Generator | Warning | Multiple IModuleOptions for same module
SM0045 | SimpleModule.Generator | Error | Feature class is not sealed
SM0046 | SimpleModule.Generator | Warning | Feature field naming violation
SM0047 | SimpleModule.Generator | Error | Duplicate feature name
SM0048 | SimpleModule.Generator | Error | Feature field is not a const string
53 changes: 53 additions & 0 deletions framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal readonly record struct DiscoveryData(
ImmutableArray<ContractInterfaceInfoRecord> ContractInterfaces,
ImmutableArray<ContractImplementationRecord> ContractImplementations,
ImmutableArray<PermissionClassRecord> PermissionClasses,
ImmutableArray<FeatureClassRecord> FeatureClasses,
ImmutableArray<InterceptorInfoRecord> Interceptors,
ImmutableArray<VogenValueObjectRecord> VogenValueObjects,
ImmutableArray<ModuleOptionsRecord> ModuleOptions,
Expand All @@ -43,6 +44,7 @@ string HostAssemblyName
ImmutableArray<ContractInterfaceInfoRecord>.Empty,
ImmutableArray<ContractImplementationRecord>.Empty,
ImmutableArray<PermissionClassRecord>.Empty,
ImmutableArray<FeatureClassRecord>.Empty,
ImmutableArray<InterceptorInfoRecord>.Empty,
ImmutableArray<VogenValueObjectRecord>.Empty,
ImmutableArray<ModuleOptionsRecord>.Empty,
Expand All @@ -60,6 +62,7 @@ public bool Equals(DiscoveryData other)
&& ContractInterfaces.SequenceEqual(other.ContractInterfaces)
&& ContractImplementations.SequenceEqual(other.ContractImplementations)
&& PermissionClasses.SequenceEqual(other.PermissionClasses)
&& FeatureClasses.SequenceEqual(other.FeatureClasses)
&& Interceptors.SequenceEqual(other.Interceptors)
&& VogenValueObjects.SequenceEqual(other.VogenValueObjects)
&& ModuleOptions.SequenceEqual(other.ModuleOptions)
Expand All @@ -78,6 +81,7 @@ public override int GetHashCode()
hash = HashHelper.HashArray(hash, ContractInterfaces);
hash = HashHelper.HashArray(hash, ContractImplementations);
hash = HashHelper.HashArray(hash, PermissionClasses);
hash = HashHelper.HashArray(hash, FeatureClasses);
hash = HashHelper.HashArray(hash, Interceptors);
hash = HashHelper.HashArray(hash, VogenValueObjects);
hash = HashHelper.HashArray(hash, ModuleOptions);
Expand All @@ -95,6 +99,7 @@ internal readonly record struct ModuleInfoRecord(
bool HasConfigurePermissions,
bool HasConfigureMiddleware,
bool HasConfigureSettings,
bool HasConfigureFeatureFlags,
bool HasRazorComponents,
string RoutePrefix,
string ViewPrefix,
Expand All @@ -112,6 +117,7 @@ public bool Equals(ModuleInfoRecord other)
&& HasConfigureMiddleware == other.HasConfigureMiddleware
&& HasConfigurePermissions == other.HasConfigurePermissions
&& HasConfigureSettings == other.HasConfigureSettings
&& HasConfigureFeatureFlags == other.HasConfigureFeatureFlags
&& HasRazorComponents == other.HasRazorComponents
&& RoutePrefix == other.RoutePrefix
&& ViewPrefix == other.ViewPrefix
Expand All @@ -130,6 +136,7 @@ public override int GetHashCode()
hash = HashHelper.Combine(hash, HasConfigureMiddleware.GetHashCode());
hash = HashHelper.Combine(hash, HasConfigurePermissions.GetHashCode());
hash = HashHelper.Combine(hash, HasConfigureSettings.GetHashCode());
hash = HashHelper.Combine(hash, HasConfigureFeatureFlags.GetHashCode());
hash = HashHelper.Combine(hash, HasRazorComponents.GetHashCode());
hash = HashHelper.Combine(hash, (RoutePrefix ?? "").GetHashCode());
hash = HashHelper.Combine(hash, (ViewPrefix ?? "").GetHashCode());
Expand Down Expand Up @@ -295,6 +302,36 @@ internal readonly record struct PermissionFieldRecord(
bool IsConstString
);

internal readonly record struct FeatureClassRecord(
string FullyQualifiedName,
string ModuleName,
bool IsSealed,
ImmutableArray<FeatureFieldRecord> Fields
)
{
public bool Equals(FeatureClassRecord other) =>
FullyQualifiedName == other.FullyQualifiedName
&& ModuleName == other.ModuleName
&& IsSealed == other.IsSealed
&& Fields.SequenceEqual(other.Fields);

public override int GetHashCode()
{
var hash = 17;
hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
hash = HashHelper.Combine(hash, IsSealed.GetHashCode());
hash = HashHelper.HashArray(hash, Fields);
return hash;
}
}

internal readonly record struct FeatureFieldRecord(
string FieldName,
string Value,
bool IsConstString
);

internal readonly record struct InterceptorInfoRecord(
string FullyQualifiedName,
string ModuleName,
Expand Down Expand Up @@ -359,6 +396,7 @@ internal sealed class ModuleInfo
public bool HasConfigurePermissions { get; set; }
public bool HasConfigureMiddleware { get; set; }
public bool HasConfigureSettings { get; set; }
public bool HasConfigureFeatureFlags { get; set; }
public bool HasRazorComponents { get; set; }
public string RoutePrefix { get; set; } = "";
public string ViewPrefix { get; set; } = "";
Expand Down Expand Up @@ -450,6 +488,21 @@ internal sealed class PermissionFieldInfo
public bool IsConstString { get; set; }
}

internal sealed class FeatureClassInfo
{
public string FullyQualifiedName { get; set; } = "";
public string ModuleName { get; set; } = "";
public bool IsSealed { get; set; }
public List<FeatureFieldInfo> Fields { get; set; } = new();
}

internal sealed class FeatureFieldInfo
{
public string FieldName { get; set; } = "";
public string Value { get; set; } = "";
public bool IsConstString { get; set; }
}

internal sealed class InterceptorInfo
{
public string FullyQualifiedName { get; set; } = "";
Expand Down
Loading