diff --git a/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/Program.cs b/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/Program.cs
index 49d1e87..3cd99bc 100644
--- a/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/Program.cs
+++ b/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/Program.cs
@@ -1,7 +1,21 @@
+using Bitwarden.Extensions.Hosting.Features;
+
var builder = WebApplication.CreateBuilder(args);
builder.UseBitwardenDefaults();
var app = builder.Build();
+app.UseRouting();
+
+app.UseFeatureFlagChecks();
+
+app.MapGet("/", (IConfiguration config) => ((IConfigurationRoot)config).GetDebugView());
+
+app.MapGet("/requires-feature", (IFeatureService featureService) =>
+{
+ return featureService.GetAll();
+})
+ .RequireFeature("feature-one");
+
app.Run();
diff --git a/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/appsettings.Development.json b/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/appsettings.Development.json
index 0c208ae..28d6b86 100644
--- a/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/appsettings.Development.json
+++ b/extensions/Bitwarden.Extensions.Hosting/examples/MinimalApi/appsettings.Development.json
@@ -4,5 +4,13 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
+ },
+ "Features": {
+ "FlagValues": {
+ "feature-one": true,
+ "feature-two": 1,
+ "feature-three": "my-value"
+ },
+ "KnownFlags": ["feature-one", "feature-two"]
}
}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/AssemblyHelpers.cs b/extensions/Bitwarden.Extensions.Hosting/src/AssemblyHelpers.cs
deleted file mode 100644
index aa78691..0000000
--- a/extensions/Bitwarden.Extensions.Hosting/src/AssemblyHelpers.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using System.Reflection;
-
-namespace Bitwarden.Extensions.Hosting;
-
-///
-/// Helper class for working with assembly attributes.
-///
-public static class AssemblyHelpers
-{
- private const string _gitHashAssemblyKey = "GitHash";
-
- private static readonly IEnumerable _assemblyMetadataAttributes;
- private static readonly AssemblyInformationalVersionAttribute? _assemblyInformationalVersionAttributes;
- private static string? _version;
- private static string? _gitHash;
-
- static AssemblyHelpers()
- {
- _assemblyMetadataAttributes = Assembly.GetEntryAssembly()!
- .GetCustomAttributes();
- _assemblyInformationalVersionAttributes = Assembly.GetEntryAssembly()!
- .GetCustomAttribute();
- }
-
- ///
- /// Gets the version of the entry assembly.
- ///
- ///
- public static string? GetVersion()
- {
- if (string.IsNullOrWhiteSpace(_version))
- {
- _version = _assemblyInformationalVersionAttributes?.InformationalVersion;
- }
-
- return _version;
- }
-
- ///
- /// Gets the Git hash of the entry assembly.
- ///
- ///
- public static string? GetGitHash()
- {
- if (string.IsNullOrWhiteSpace(_gitHash))
- {
- try
- {
- _gitHash = _assemblyMetadataAttributes.First(i =>
- i.Key == _gitHashAssemblyKey).Value;
- }
- catch (Exception)
- {
- // suppress
- }
- }
-
- return _gitHash;
- }
-}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs b/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs
index a2f3da2..2fc9f0e 100644
--- a/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Attributes/SelfHostedAttribute.cs
@@ -1,4 +1,4 @@
-using Bitwarden.Extensions.Hosting.Exceptions;
+using Bitwarden.Extensions.Hosting.Exceptions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj b/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj
index 1adc280..6980999 100644
--- a/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Bitwarden.Extensions.Hosting.csproj
@@ -22,6 +22,7 @@
+
@@ -43,5 +44,9 @@
-
+
+
+
+
+
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs b/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs
index cad3f1b..c844be6 100644
--- a/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs
+++ b/extensions/Bitwarden.Extensions.Hosting/src/BitwardenHostOptions.cs
@@ -1,4 +1,4 @@
-namespace Bitwarden.Extensions.Hosting;
+namespace Bitwarden.Extensions.Hosting;
///
/// Options for configuring the Bitwarden host.
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureApplicationBuilderExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureApplicationBuilderExtensions.cs
new file mode 100644
index 0000000..30c8ac6
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureApplicationBuilderExtensions.cs
@@ -0,0 +1,29 @@
+using Bitwarden.Extensions.Hosting.Features;
+
+namespace Microsoft.AspNetCore.Builder;
+
+///
+/// Feature extension methods for .
+///
+public static class FeatureApplicationBuilderExtensions
+{
+ ///
+ /// Adds the to the specified , which enabled feature check capabilities.
+ ///
+ /// This call must take place between app.UseRouting() and app.UseEndpoints(...) for middleware to function properly.
+ ///
+ ///
+ /// The to add the middleware to.
+ /// A reference to after the operation has completed.
+ public static IApplicationBuilder UseFeatureFlagChecks(this IApplicationBuilder app)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+
+ // This would be a good time to make sure that IFeatureService is registered but it is a scoped service
+ // and I don't think creating a scope is worth it for that. If we think this is a problem we can add another
+ // marker interface that is registered as a singleton and validate that it exists here.
+
+ app.UseMiddleware();
+ return app;
+ }
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureCheckMiddleware.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureCheckMiddleware.cs
new file mode 100644
index 0000000..b5e7336
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureCheckMiddleware.cs
@@ -0,0 +1,93 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Bitwarden.Extensions.Hosting.Features;
+
+internal sealed class FeatureCheckMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly IHostEnvironment _hostEnvironment;
+ private readonly IProblemDetailsService _problemDetailsService;
+ private readonly ILogger _logger;
+
+ public FeatureCheckMiddleware(
+ RequestDelegate next,
+ IHostEnvironment hostEnvironment,
+ IProblemDetailsService problemDetailsService,
+ ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(next);
+ ArgumentNullException.ThrowIfNull(hostEnvironment);
+ ArgumentNullException.ThrowIfNull(problemDetailsService);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ _next = next;
+ _hostEnvironment = hostEnvironment;
+ _problemDetailsService = problemDetailsService;
+ _logger = logger;
+ }
+
+ public Task Invoke(HttpContext context, IFeatureService featureService)
+ {
+ // This middleware is expected to be placed after `UseRouting()` which will fill in this endpoint
+ var endpoint = context.GetEndpoint();
+
+ if (endpoint == null)
+ {
+ _logger.LogNoEndpointWarning();
+ return _next(context);
+ }
+
+ var featureMetadatas = endpoint.Metadata.GetOrderedMetadata();
+
+ foreach (var featureMetadata in featureMetadatas)
+ {
+ if (!featureMetadata.FeatureCheck(featureService))
+ {
+ // Do not execute more of the pipeline, return early.
+ return HandleFailedFeatureCheck(context, featureMetadata);
+ }
+
+ // Continue checking
+ }
+
+ // Either there were no feature checks, or none were failed. Continue on in the pipeline.
+ return _next(context);
+ }
+
+ private async Task HandleFailedFeatureCheck(HttpContext context, IFeatureMetadata failedFeature)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogFailedFeatureCheck(failedFeature.ToString()!);
+ }
+
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+
+ var problemDetails = new ProblemDetails();
+ problemDetails.Title = "Resource not found.";
+ problemDetails.Status = StatusCodes.Status404NotFound;
+
+ // Message added for legacy reasons. We should start preferring title/detail
+ problemDetails.Extensions["Message"] = "Resource not found.";
+
+ // Follow ProblemDetails output type? Would need clients update
+ if (_hostEnvironment.IsDevelopment())
+ {
+ // Add extra information
+ problemDetails.Detail = $"Feature check failed: {failedFeature}";
+ }
+
+ // We don't really care if this fails, we will return the 404 no matter what.
+ await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
+ {
+ HttpContext = context,
+ ProblemDetails = problemDetails,
+ // TODO: Add metadata?
+ });
+ }
+
+
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureEndpointConventionBuilderExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureEndpointConventionBuilderExtensions.cs
new file mode 100644
index 0000000..bdfa620
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureEndpointConventionBuilderExtensions.cs
@@ -0,0 +1,49 @@
+using Bitwarden.Extensions.Hosting.Features;
+
+namespace Microsoft.AspNetCore.Builder;
+
+///
+/// Feature extension methods for .
+///
+public static class FeatureEndpointConventionBuilderExtensions
+{
+ ///
+ /// Adds a feature check for the given feature to the endpoint(s).
+ ///
+ /// The endpoint convention builder.
+ ///
+ ///
+ public static TBuilder RequireFeature(this TBuilder builder, string featureNameKey)
+ where TBuilder : IEndpointConventionBuilder
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(featureNameKey);
+
+ builder.Add(endpointBuilder =>
+ {
+ endpointBuilder.Metadata.Add(new RequireFeatureAttribute(featureNameKey));
+ });
+
+ return builder;
+ }
+
+ ///
+ /// Adds a feature check with the specified check to the endpoint(s).
+ ///
+ /// The endpoint convention builder.
+ ///
+ ///
+ public static TBuilder RequireFeature(this TBuilder builder, Func featureCheck)
+ where TBuilder : IEndpointConventionBuilder
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(featureCheck);
+
+ builder.Add(endpointBuilder =>
+ {
+ endpointBuilder.Metadata.Add(new RequireFeatureAttribute(featureCheck));
+ });
+
+ return builder;
+ }
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureFlagOptions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureFlagOptions.cs
new file mode 100644
index 0000000..1caf152
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureFlagOptions.cs
@@ -0,0 +1,33 @@
+namespace Bitwarden.Extensions.Hosting.Features;
+
+///
+/// A collection of Launch Darkly specific options.
+///
+public sealed class LaunchDarklyOptions
+{
+ ///
+ /// The SdkKey to be used for retrieving feature flag values from Launch Darkly.
+ ///
+ public string? SdkKey { get; set; }
+}
+
+///
+/// A set of options for features.
+///
+public sealed class FeatureFlagOptions
+{
+ ///
+ /// All the flags known to this instance, this is used to enumerable values in .
+ ///
+ public HashSet KnownFlags { get; set; } = new HashSet();
+
+ ///
+ /// Flags and flag values to include in the feature flag data source.
+ ///
+ public Dictionary FlagValues { get; set; } = new Dictionary();
+
+ ///
+ /// Launch Darkly specific options.
+ ///
+ public LaunchDarklyOptions LaunchDarkly { get; set; } = new LaunchDarklyOptions();
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureServiceCollectionExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureServiceCollectionExtensions.cs
new file mode 100644
index 0000000..b617b1d
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/FeatureServiceCollectionExtensions.cs
@@ -0,0 +1,54 @@
+using Bitwarden.Extensions.Hosting.Features;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Extensions for features on .
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Adds known feature flags to the . This makes these flags
+ /// show up in .
+ ///
+ /// The service collection to customize the options on.
+ /// The flags to add to the known flags list.
+ /// The to chain additional calls.
+ public static IServiceCollection AddKnownFeatureFlags(this IServiceCollection services, IEnumerable knownFlags)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.Configure(options =>
+ {
+ foreach (var flag in knownFlags)
+ {
+ options.KnownFlags.Add(flag);
+ }
+ });
+
+ return services;
+ }
+
+ ///
+ /// Adds feature flags and their values to the .
+ ///
+ /// The service collection to customize the options on.
+ /// The flags to add to the flag values dictionary.
+ /// The to chain additional calls.
+ public static IServiceCollection AddFeatureFlagValues(
+ this IServiceCollection services,
+ IEnumerable> flagValues)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.Configure(options =>
+ {
+ foreach (var flag in flagValues)
+ {
+ options.FlagValues[flag.Key] = flag.Value;
+ }
+ });
+
+ return services;
+ }
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureMetadata.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureMetadata.cs
new file mode 100644
index 0000000..693b0fc
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureMetadata.cs
@@ -0,0 +1,9 @@
+namespace Bitwarden.Extensions.Hosting.Features;
+
+internal interface IFeatureMetadata
+{
+ ///
+ /// A method to run to check if the feature is enabled.
+ ///
+ Func FeatureCheck { get; set; }
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureService.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureService.cs
new file mode 100644
index 0000000..c0961a3
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/IFeatureService.cs
@@ -0,0 +1,39 @@
+using System.Text.Json.Nodes;
+
+namespace Bitwarden.Extensions.Hosting.Features;
+
+///
+/// Checks feature status for the current request.
+///
+public interface IFeatureService
+{
+ ///
+ /// Checks whether a given feature is enabled.
+ ///
+ /// The key of the feature to check.
+ /// The default value for the feature.
+ /// True if the feature is enabled, otherwise false.
+ bool IsEnabled(string key, bool defaultValue = false);
+
+ ///
+ /// Gets the integer variation of a feature.
+ ///
+ /// The key of the feature to check.
+ /// The default value for the feature.
+ /// The feature variation value.
+ int GetIntVariation(string key, int defaultValue = 0);
+
+ ///
+ /// Gets the string variation of a feature.
+ ///
+ /// The key of the feature to check.
+ /// The default value for the feature.
+ /// The feature variation value.
+ string? GetStringVariation(string key, string? defaultValue = null);
+
+ ///
+ /// Gets all feature values.
+ ///
+ /// A dictionary of feature keys and their values.
+ IReadOnlyDictionary GetAll();
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/LaunchDarklyFeatureService.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/LaunchDarklyFeatureService.cs
new file mode 100644
index 0000000..dac588e
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/LaunchDarklyFeatureService.cs
@@ -0,0 +1,234 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Security.Claims;
+using System.Text.Json.Nodes;
+using Bitwarden.Extensions.Hosting.Utilities;
+using LaunchDarkly.Logging;
+using LaunchDarkly.Sdk;
+using LaunchDarkly.Sdk.Server;
+using LaunchDarkly.Sdk.Server.Integrations;
+using LaunchDarkly.Sdk.Server.Interfaces;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Bitwarden.Extensions.Hosting.Features;
+
+internal sealed class LaunchDarklyFeatureService : IFeatureService
+{
+ const string AnonymousUser = "25a15cac-58cf-4ac0-ad0f-b17c4bd92294";
+
+ private readonly ILdClient _ldClient;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly IOptionsMonitor _featureFlagOptions;
+ private readonly ILogger _logger;
+
+ // Should not change during the course of a request, so cache this
+ private Context? _context;
+
+ public LaunchDarklyFeatureService(
+ ILdClient ldClient,
+ IHttpContextAccessor httpContextAccessor,
+ IOptionsMonitor featureFlagOptions,
+ ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(ldClient);
+ ArgumentNullException.ThrowIfNull(httpContextAccessor);
+ ArgumentNullException.ThrowIfNull(featureFlagOptions);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ _ldClient = ldClient;
+ _httpContextAccessor = httpContextAccessor;
+ _featureFlagOptions = featureFlagOptions;
+ _logger = logger;
+ }
+
+ public bool IsEnabled(string key, bool defaultValue = false)
+ {
+ return _ldClient.BoolVariation(key, GetContext(), defaultValue);
+ }
+
+ public int GetIntVariation(string key, int defaultValue = 0)
+ {
+ return _ldClient.IntVariation(key, GetContext(), defaultValue);
+ }
+
+ public string GetStringVariation(string key, string? defaultValue = null)
+ {
+ return _ldClient.StringVariation(key, GetContext(), defaultValue);
+ }
+
+ public IReadOnlyDictionary GetAll()
+ {
+ var flagsState = _ldClient.AllFlagsState(GetContext());
+
+ var flagValues = new Dictionary();
+
+ if (!flagsState.Valid)
+ {
+ return flagValues;
+ }
+
+ foreach (var knownFlag in _featureFlagOptions.CurrentValue.KnownFlags)
+ {
+ var ldValue = flagsState.GetFlagValueJson(knownFlag);
+
+ switch (ldValue.Type)
+ {
+ case LdValueType.Bool:
+ flagValues.Add(knownFlag, (JsonValue)ldValue.AsBool);
+ break;
+ case LdValueType.Number:
+ flagValues.Add(knownFlag, (JsonValue)ldValue.AsInt);
+ break;
+ case LdValueType.String:
+ flagValues.Add(knownFlag, (JsonValue)ldValue.AsString);
+ break;
+ }
+ }
+
+ return flagValues;
+ }
+
+ private Context GetContext()
+ {
+ return _context ??= BuildContext();
+ }
+
+ private Context BuildContext()
+ {
+ static void AddCommon(ContextBuilder contextBuilder, HttpContext httpContext)
+ {
+ if (httpContext.Request.Headers.TryGetValue("bitwarden-client-version", out var clientVersion))
+ {
+ contextBuilder.Set("client-version", clientVersion);
+ }
+
+ if (httpContext.Request.Headers.TryGetValue("device-type", out var deviceType))
+ {
+ contextBuilder.Set("device-type", deviceType);
+ }
+ }
+
+ var httpContext = _httpContextAccessor.HttpContext;
+ if (httpContext == null)
+ {
+ _logger.LogMissingHttpContext();
+ return Context.Builder(AnonymousUser)
+ .Kind(ContextKind.Default)
+ .Anonymous(true)
+ .Build();
+ }
+
+ // TODO: We need to start enforcing this
+ var subject = httpContext.User.FindFirstValue("sub");
+
+ if (string.IsNullOrEmpty(subject))
+ {
+ // Anonymous but with common headers
+ var anon = Context.Builder(AnonymousUser)
+ .Kind(ContextKind.Default)
+ .Anonymous(true);
+
+ AddCommon(anon, httpContext);
+ return anon.Build();
+ }
+
+ // TODO: Need to start enforcing this
+ var organizations = httpContext.User.FindAll("organization");
+
+ var contextBuilder = Context.Builder(subject)
+ .Kind(ContextKind.Default) // TODO: This is not right
+ .Set("organizations", LdValue.ArrayFrom(organizations.Select(c => LdValue.Of(c.Value))));
+
+ AddCommon(contextBuilder, httpContext);
+
+ return contextBuilder.Build();
+ }
+}
+
+
+internal sealed class LaunchDarklyClientProvider
+{
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly IHostEnvironment _hostEnvironment;
+ private readonly VersionInfo _versionInfo;
+
+ private LdClient _client;
+
+ public LaunchDarklyClientProvider(
+ IOptionsMonitor featureFlagOptions,
+ ILoggerFactory loggerFactory,
+ IHostEnvironment hostEnvironment)
+ {
+ _loggerFactory = loggerFactory;
+ _hostEnvironment = hostEnvironment;
+
+ var versionInfo = _hostEnvironment.GetVersionInfo();
+
+ if (versionInfo == null)
+ {
+ throw new InvalidOperationException("Unable to attain version information for the current application.");
+ }
+
+ _versionInfo = versionInfo;
+
+ BuildClient(featureFlagOptions.CurrentValue);
+ // Subscribe to options changes.
+ featureFlagOptions.OnChange(BuildClient);
+ }
+
+ [MemberNotNull(nameof(_client))]
+ private void BuildClient(FeatureFlagOptions featureFlagOptions)
+ {
+ var builder = Configuration.Builder(featureFlagOptions.LaunchDarkly.SdkKey)
+ .Logging(Components.Logging().Adapter(Logs.CoreLogging(_loggerFactory)))
+ .ApplicationInfo(Components.ApplicationInfo()
+ .ApplicationId(_hostEnvironment.ApplicationName)
+ .ApplicationName(_hostEnvironment.ApplicationName)
+ .ApplicationVersion(_versionInfo.GitHash ?? _versionInfo.Version.ToString())
+ .ApplicationVersionName(_versionInfo.Version.ToString())
+ )
+ .DataSource(BuildDataSource(featureFlagOptions.FlagValues))
+ .Events(Components.NoEvents);
+
+ _client?.Dispose();
+ _client = new LdClient(builder.Build());
+ }
+
+ private TestData BuildDataSource(Dictionary data)
+ {
+ // TODO: We could support updating just the test data source with
+ // changes from the OnChange of options, we currently support it through creating
+ // a whole new client but that could be pretty heavy just for flag
+ // value changes.
+ var source = TestData.DataSource();
+
+ foreach (var (key, value) in data)
+ {
+ var flag = source.Flag(key);
+ var valueSpan = value.AsSpan();
+ if (bool.TryParse(valueSpan, out var boolValue))
+ {
+ flag.ValueForAll(LdValue.Of(boolValue));
+ }
+ else if (int.TryParse(valueSpan, out var intValue))
+ {
+ flag.ValueForAll(LdValue.Of(intValue));
+ }
+ else
+ {
+ flag.ValueForAll(LdValue.Of(value));
+ }
+
+ source.Update(flag);
+ }
+
+ return source;
+ }
+
+ public LdClient Get()
+ {
+ return _client;
+ }
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/LoggerExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/LoggerExtensions.cs
new file mode 100644
index 0000000..9314ae0
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/LoggerExtensions.cs
@@ -0,0 +1,15 @@
+using Microsoft.Extensions.Logging;
+
+namespace Bitwarden.Extensions.Hosting.Features;
+
+internal static partial class LoggerExtensions
+{
+ [LoggerMessage(1, LogLevel.Warning, "No endpoint set -- did you forget to call 'UseRouting()'?")]
+ public static partial void LogNoEndpointWarning(this ILogger logger);
+
+ [LoggerMessage(2, LogLevel.Debug, "Failed feature check {CheckName}", SkipEnabledCheck = true)]
+ public static partial void LogFailedFeatureCheck(this ILogger logger, string checkName);
+
+ [LoggerMessage(3, LogLevel.Warning, "No HttpContext available for the current feature flag check.")]
+ public static partial void LogMissingHttpContext(this ILogger logger);
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Features/RequireFeatureAttribute.cs b/extensions/Bitwarden.Extensions.Hosting/src/Features/RequireFeatureAttribute.cs
new file mode 100644
index 0000000..af0102f
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Features/RequireFeatureAttribute.cs
@@ -0,0 +1,39 @@
+namespace Bitwarden.Extensions.Hosting.Features;
+
+///
+/// Specifies that the class or method that this attribute is applied to requires a feature check to run.
+///
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public sealed class RequireFeatureAttribute : Attribute, IFeatureMetadata
+{
+ private readonly string _stringRepresentation;
+
+ ///
+ /// Initializes a new instance of
+ ///
+ /// The feature flag that should be enabled.
+ public RequireFeatureAttribute(string featureFlagKey)
+ {
+ ArgumentNullException.ThrowIfNull(featureFlagKey);
+
+ _stringRepresentation = $"Flag = {featureFlagKey}";
+ FeatureCheck = (featureService) => featureService.IsEnabled(featureFlagKey);
+ }
+
+ internal RequireFeatureAttribute(Func featureCheck)
+ {
+ ArgumentNullException.ThrowIfNull(featureCheck);
+
+ FeatureCheck = featureCheck;
+ _stringRepresentation = "Custom Feature Check";
+ }
+
+ ///
+ public Func FeatureCheck { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return _stringRepresentation;
+ }
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs b/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs
index 0d44d21..cc5bf4b 100644
--- a/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs
+++ b/extensions/Bitwarden.Extensions.Hosting/src/GlobalSettingsBase.cs
@@ -1,4 +1,4 @@
-namespace Bitwarden.Extensions.Hosting;
+namespace Bitwarden.Extensions.Hosting;
///
/// Global settings.
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs
index 73d0d44..7612f5b 100644
--- a/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs
+++ b/extensions/Bitwarden.Extensions.Hosting/src/HostBuilderExtensions.cs
@@ -1,15 +1,15 @@
-using System.Diagnostics;
+using System.Diagnostics;
using System.Reflection;
using Bitwarden.Extensions.Hosting;
-using Microsoft.AspNetCore.Hosting;
+using Bitwarden.Extensions.Hosting.Features;
+using LaunchDarkly.Sdk.Server.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using Serilog;
-using Serilog.Events;
using Serilog.Formatting.Compact;
namespace Microsoft.Extensions.Hosting;
@@ -21,34 +21,36 @@ public static class HostBuilderExtensions
{
const string SelfHostedConfigKey = "globalSettings:selfHosted";
- ///
- /// Gets a logger that is suitable for use during the bootstrapping (startup) process.
- ///
- ///
- public static ILogger GetBootstrapLogger()
- {
- return new LoggerConfiguration()
- .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
- .Enrich.FromLogContext()
- .WriteTo.Console()
- .CreateBootstrapLogger();
- }
-
///
/// Configures the host to use Bitwarden defaults.
///
+ /// The host application builder.
+ /// The function to customize the Bitwarden defaults.
+ /// The original host application builder parameter.
public static TBuilder UseBitwardenDefaults(this TBuilder builder, Action? configure = null)
where TBuilder : IHostApplicationBuilder
{
+ ArgumentNullException.ThrowIfNull(builder);
+
var bitwardenHostOptions = new BitwardenHostOptions();
configure?.Invoke(bitwardenHostOptions);
builder.UseBitwardenDefaults(bitwardenHostOptions);
return builder;
}
+ ///
+ /// Configures the host to use Bitwarden defaults.
+ ///
+ ///
+ ///
+ ///
+ ///
public static TBuilder UseBitwardenDefaults(this TBuilder builder, BitwardenHostOptions bitwardenHostOptions)
where TBuilder : IHostApplicationBuilder
{
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(bitwardenHostOptions);
+
builder.Services.AddOptions()
.Configure((options, config) =>
{
@@ -71,6 +73,8 @@ public static TBuilder UseBitwardenDefaults(this TBuilder builder, Bit
AddMetrics(builder.Services);
}
+ AddFeatureFlagServices(builder.Services, builder.Configuration);
+
return builder;
}
@@ -126,6 +130,11 @@ public static IHostBuilder UseBitwardenDefaults(this IHostBuilder hostBuilder, B
});
}
+ hostBuilder.ConfigureServices((context, services) =>
+ {
+ AddFeatureFlagServices(services, context.Configuration);
+ });
+
return hostBuilder;
}
@@ -191,11 +200,19 @@ private static void AddLogging(IServiceCollection services, IConfiguration confi
{
services.AddSerilog((sp, serilog) =>
{
- serilog.ReadFrom.Configuration(configuration)
+ var builder = serilog.ReadFrom.Configuration(configuration)
.ReadFrom.Services(sp)
.Enrich.WithProperty("Project", environment.ApplicationName)
- .Enrich.FromLogContext()
- .WriteTo.Console(new RenderedCompactJsonFormatter());
+ .Enrich.FromLogContext();
+
+ if (environment.IsProduction())
+ {
+ builder.WriteTo.Console(new RenderedCompactJsonFormatter());
+ }
+ else
+ {
+ builder.WriteTo.Console();
+ }
});
}
@@ -207,4 +224,21 @@ private static void AddMetrics(IServiceCollection services)
.WithTracing(options =>
options.AddOtlpExporter());
}
+
+ private static void AddFeatureFlagServices(IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddProblemDetails();
+ services.AddHttpContextAccessor();
+
+ services.Configure(configuration.GetSection("Features"));
+ // TODO: Register service to do legacy support from configuration.
+
+ services.TryAddSingleton();
+
+ // This needs to be scoped so a "new" ILdClient can be given per request, this makes it possible to
+ // have the ILdClient be rebuilt if configuration changes but for the most part this will return a cached
+ // client from LaunchDarklyClientProvider, effectively being a singleton.
+ services.TryAddScoped(sp => sp.GetRequiredService().Get());
+ services.TryAddScoped();
+ }
}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Utilities/HostEnvironmentExtensions.cs b/extensions/Bitwarden.Extensions.Hosting/src/Utilities/HostEnvironmentExtensions.cs
new file mode 100644
index 0000000..c2f9d2f
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Utilities/HostEnvironmentExtensions.cs
@@ -0,0 +1,33 @@
+using System.Reflection;
+using Microsoft.Extensions.Hosting;
+
+namespace Bitwarden.Extensions.Hosting.Utilities;
+
+internal static class HostEnvironmentExtensions
+{
+ public static VersionInfo? GetVersionInfo(this IHostEnvironment hostEnvironment)
+ {
+ try
+ {
+ var appAssembly = Assembly.Load(new AssemblyName(hostEnvironment.ApplicationName));
+ var assemblyInformationalVersionAttribute = appAssembly
+ .GetCustomAttribute();
+
+ if (assemblyInformationalVersionAttribute == null)
+ {
+ return null;
+ }
+
+ if (!VersionInfo.TryParse(assemblyInformationalVersionAttribute.InformationalVersion, null, out var versionInfo))
+ {
+ return null;
+ }
+
+ return versionInfo;
+ }
+ catch (FileNotFoundException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/src/Utilities/VersionInfo.cs b/extensions/Bitwarden.Extensions.Hosting/src/Utilities/VersionInfo.cs
new file mode 100644
index 0000000..03c97bb
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/src/Utilities/VersionInfo.cs
@@ -0,0 +1,75 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
+
+namespace Bitwarden.Extensions.Hosting;
+
+internal sealed partial class VersionInfo : ISpanParsable
+{
+ [GeneratedRegex("[0-9a-f]{5,40}")]
+ private static partial Regex GitHashRegex();
+
+ private VersionInfo(Version version, string? gitHash)
+ {
+ Version = version;
+ GitHash = gitHash;
+ }
+
+ public Version Version { get; }
+ public string? GitHash { get; }
+
+ ///
+ public static VersionInfo Parse(ReadOnlySpan s, IFormatProvider? provider)
+ {
+ if (!TryParse(s, provider, out var result))
+ {
+ throw new FormatException();
+ }
+
+ return result;
+ }
+
+ ///
+ public static VersionInfo Parse(string? s, IFormatProvider? provider)
+ => Parse(s.AsSpan(), provider);
+
+ ///
+ public static bool TryParse(
+ ReadOnlySpan s,
+ IFormatProvider? provider,
+ [MaybeNullWhen(returnValue: false)] out VersionInfo result)
+ {
+ result = null;
+ var plusIndex = s.IndexOf('+');
+
+ if (plusIndex == -1)
+ {
+ // No split char, treat it as version only
+ if (!Version.TryParse(s, out var versionOnly))
+ {
+ return false;
+ }
+
+ result = new VersionInfo(versionOnly, null);
+ return true;
+ }
+
+ if (!Version.TryParse(s[0..plusIndex], out var version))
+ {
+ return false;
+ }
+
+ var gitHash = s[++plusIndex..];
+
+ if (!GitHashRegex().IsMatch(gitHash))
+ {
+ return false;
+ }
+
+ result = new VersionInfo(version, gitHash.ToString());
+ return true;
+ }
+
+ ///
+ public static bool TryParse(string? s, IFormatProvider? provider, [MaybeNullWhen(returnValue: false)] out VersionInfo result)
+ => TryParse(s.AsSpan(), provider, out result);
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj b/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj
index e203e24..5568240 100644
--- a/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj
+++ b/extensions/Bitwarden.Extensions.Hosting/tests/Bitwarden.Extensions.Hosting.Tests.csproj
@@ -14,6 +14,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureApplicationBuilderExtensionsTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureApplicationBuilderExtensionsTests.cs
new file mode 100644
index 0000000..5154ba2
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureApplicationBuilderExtensionsTests.cs
@@ -0,0 +1,54 @@
+using Bitwarden.Extensions.Hosting.Features;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using NSubstitute;
+
+namespace Bitwarden.Extensions.Hosting.Tests.Features;
+
+public class FeatureApplicationBuilderExtensionsTests
+{
+ [Fact]
+ public async Task UseFeatureFlagChecks_RegistersMiddleware()
+ {
+ // Arrange
+ var featureService = Substitute.For();
+ var services = CreateServices(featureService);
+
+ var app = new ApplicationBuilder(services);
+
+ app.UseFeatureFlagChecks();
+
+ var appFunc = app.Build();
+
+ var endpoint = new Endpoint(
+ null,
+ new EndpointMetadataCollection(new RequireFeatureAttribute("test-attribute")),
+ "Test endpoint");
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.RequestServices = services;
+ httpContext.SetEndpoint(endpoint);
+
+ // Act
+ await appFunc(httpContext);
+
+ // Assert
+ featureService.Received(1).IsEnabled("test-attribute");
+ }
+
+ private IServiceProvider CreateServices(IFeatureService featureService)
+ {
+ var services = new ServiceCollection();
+
+ services.AddLogging();
+ services.AddProblemDetails(options => { });
+ services.AddSingleton(Substitute.For());
+ services.AddSingleton(featureService);
+
+ var serviceProvder = services.BuildServiceProvider();
+
+ return serviceProvder;
+ }
+}
diff --git a/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureCheckMiddlewareTests.cs b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureCheckMiddlewareTests.cs
new file mode 100644
index 0000000..c2b1bec
--- /dev/null
+++ b/extensions/Bitwarden.Extensions.Hosting/tests/Features/FeatureCheckMiddlewareTests.cs
@@ -0,0 +1,189 @@
+using System.Net;
+using System.Net.Http.Json;
+using Bitwarden.Extensions.Hosting.Features;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+
+namespace Bitwarden.Extensions.Hosting.Tests.Features;
+
+public class FeatureCheckMiddlewareTests
+{
+ private readonly IProblemDetailsService _fakeProblemDetailsService;
+ private readonly IHostEnvironment _fakeHostEnvironment;
+
+ public FeatureCheckMiddlewareTests()
+ {
+ _fakeProblemDetailsService = Substitute.For();
+ _fakeHostEnvironment = Substitute.For();
+ }
+
+ [Fact]
+ public async Task NoEndpointInvokesPipeline()
+ {
+ bool pipelineInvoked = false;
+ var middleware = new FeatureCheckMiddleware(hc =>
+ {
+ pipelineInvoked = true;
+ return Task.CompletedTask;
+ }, _fakeHostEnvironment, _fakeProblemDetailsService, NullLogger.Instance);
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.SetEndpoint(null);
+
+ var featureService = Substitute.For();
+
+ await middleware.Invoke(httpContext, featureService);
+
+ Assert.True(pipelineInvoked);
+ }
+
+ public static IEnumerable