Skip to content

Commit

Permalink
Feature Flags Support (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
derekmckinnon authored Jan 9, 2024
1 parent 75f221e commit b274580
Show file tree
Hide file tree
Showing 19 changed files with 525 additions and 29 deletions.
102 changes: 100 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

An opinionated [.NET Configuration Provider](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration-providers) for AWS AppConfig.

This configuration provider supports the following freeform configuration profile type formats:

- JSON
- YAML

This provider also supports the Feature Flag configuration profile type and renders
[.NET FeatureManagement](https://github.com/microsoft/FeatureManagement-Dotnet)-compatible configuration.
Please refer to the [Feature Flag](#feature-flags) section below for more information.

## Usage

First, download the provider from NuGet:
Expand All @@ -25,7 +34,7 @@ builder.Configuration.AddAppConfig("MyCustomName");

## Configuration

The provider requires some minimal configuration in order for it to know which AppConfig profiles to load:
The provider requires some minimal configuration in order for it to know which AppConfig profiles to load:

```json
{
Expand Down Expand Up @@ -60,16 +69,105 @@ The default `ReloadAfter` setting can be overridden as well:
}
```

### Feature Flags

[Feature Flag](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-configuration-and-profile-feature-flags.html) configuration profile types
can be consumed by the provider and are automatically translated to .NET FeatureManagement-compatible configuration. Both simple and complex feature flags are supported.

To specify feature flag profiles, add the profile metadata to the `FeatureFlags` array instead of `Profiles`:

```json5
{
"AppConfig": {
"Profiles": [
// Freeform configuration profile
"abc1234:def5678:ghi9123",
// Freeform configuration profile
"q2w3e25:po92j45:bt9s090:300"
],
"FeatureFlags": [
// Feature Flag profile
"bvt1234:glw6348:zup8532"
]
}
}
```

Due to the differences between the AppConfig and FeatureManagement configuration setup and the validation constraints that AppConfig uses for feature flags,
the provider has some opinionated quirks when it comes to feature flags.

**Simple Flags**

A "simple" flag is one that can be enabled or disabled and does not contain any attributes.
AppConfig returns these as an object with a single field:

```json
{
"customFeatureFlag": {
"enabled": true
}
}
```

This gets translated to:

```json
{
"FeatureManagement": {
"CustomFeatureFlag": true
}
}
```

The provider will automatically convert the name of the flag to PascalCase.

**Complex Flags**

A "complex" flag is one that contains attributes that specify feature filters and, optionally, their parameters.

AppConfig will return these like this:

```json5
{
"complexFlag": {
"enabled": true,
"customProperty": "customValue",
// etc.
}
}
```

To get around some of the limitations of how AppConfig lets you construct attributes, the following transformations rules are in place:

- The feature name is the PascalCase version of the flag name
- The `enabled` field is always ignored
- The `requirementType` field is converted to `RequirementType`
- It must be either `All` or `Any` (default if omitted)
- Any other fields are considered to be feature filters and/or their parameters
- A parameterless filter should be named `featureFilter` and have a blank/null value (e.g. `"alwaysOn": null`)
- A filter with parameters should be named `featureFilter__parameterName` and have a value for the parameter (e.g. `"percentage__value": 50`)
- The provider uses the double underscore (`__`) to separate the filter name from the parameter name
- You can supply multiple parameters using this scheme (e.g. `"percentage__value": 50, "percentage__foobar": "baz"`)

There are likely to be some limitations with this approach, so please open an issue if you find any that don't match your use case.

## Sample

A sample ASP.NET Web Application is available in the `samples/AppConfigTesting` folder.

In your own AWS environment, copy the contents of `yamltest.yml` into a new AppConfig freeform configuration profile.

Then, create a new Feature Flag configuration profile with 2 flags: `enableFoobar` and `complexFlag`.
Feel free to add any attributes you want to `complexFlag` that match the rules described in the [Feature Flags](#feature-flags) section.

Then, use `dotnet user-secrets` to specify the AppConfig profile:

```shell
dotnet user-secrets set "AppConfig:Profiles:0" "abc1234:def5678:ghi9123"
# Freeform configuration profile
dotnet user-secrets set "AppConfig:Profiles:0" "abc1234:def5678:ghi9123" # <-- Replace with your own profile

# Feature Flag configuration profile
dotnet user-secrets set "AppConfig:FeatureFlags:0" "bvt1234:glw6348:zup8532" # <-- Replace with your own profile
```

Finally, ensure that you have the correct AWS credentials/profile configured in your environment, and run the sample:
Expand Down
4 changes: 4 additions & 0 deletions samples/AppConfigTesting/AppConfigTesting.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@
<ProjectReference Include="..\..\src\CatConsult.AppConfigConfigurationProvider\CatConsult.AppConfigConfigurationProvider.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.FeatureManagement.AspNetCore" Version="3.1.1" />
</ItemGroup>

</Project>
32 changes: 32 additions & 0 deletions samples/AppConfigTesting/Pages/Index.cshtml
Original file line number Diff line number Diff line change
@@ -1,14 +1,46 @@
@page
@using Microsoft.Extensions.Options
@using Microsoft.FeatureManagement
@model IndexModel
@inject IOptionsSnapshot<YamlTest> Options
@inject IFeatureManager FeatureManager
@{
ViewData["Title"] = "Home page";
}

<div class="text-center">
<h2>YAML Configuration</h2>

<p>Name: @Options.Value.Person?.Name</p>
<p>Age: @Options.Value.Person?.Age</p>
<br>
<p>Is this a test? @Options.Value.Test</p>
</div>

<div class="mt-3 text-center">
<h2>Features</h2>

<h3>Registered Features</h3>
@await foreach (var feature in FeatureManager.GetFeatureNamesAsync())
{
<p>
<code>@feature</code>
</p>
}

<h3>Enabled Features</h3>

<feature name="EnableFoobar">
<p><code>EnableFoobar</code> is enabled</p>
</feature>
<feature name="EnableFoobar" negate="true">
<p><code>EnableFoobar</code> is <b>not</b> enabled</p>
</feature>

<feature name="ComplexFlag">
<p><code>ComplexFlag</code> is enabled</p>
</feature>
<feature name="ComplexFlag" negate="true">
<p><code>ComplexFlag</code> is <b>not</b> enabled</p>
</feature>
</div>
1 change: 1 addition & 0 deletions samples/AppConfigTesting/Pages/_ViewImports.cshtml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@using AppConfigTesting
@namespace AppConfigTesting.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Microsoft.FeatureManagement.AspNetCore
4 changes: 4 additions & 0 deletions samples/AppConfigTesting/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using CatConsult.AppConfigConfigurationProvider;

using Microsoft.FeatureManagement;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddAppConfig();
Expand All @@ -10,6 +12,8 @@
builder.Services.AddOptions<YamlTest>()
.BindConfiguration("YamlTest");

builder.Services.AddFeatureManagement();

var app = builder.Build();

// Configure the HTTP request pipeline.
Expand Down
3 changes: 2 additions & 1 deletion samples/AppConfigTesting/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"Defaults": {
"ReloadAfter": 60
},
"Profiles": []
"Profiles": [],
"FeatureFlags": []
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Amazon.AppConfigData;
using Amazon.AppConfigData.Model;

using CatConsult.AppConfigConfigurationProvider.Utilities;
using CatConsult.ConfigurationParsers;

using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -80,6 +81,7 @@ private async Task LoadAsync()
if (response.ContentLength > 0)
{
Data = ParseConfig(response.Configuration, response.ContentType);
OnReload();
}
}
finally
Expand All @@ -100,7 +102,7 @@ private async Task InitializeAppConfigSessionAsync()
ConfigurationToken = session.InitialConfigurationToken;
}

private static IDictionary<string, string?> ParseConfig(Stream stream, string? contentType)
private IDictionary<string, string?> ParseConfig(Stream stream, string? contentType)
{
if (!string.IsNullOrEmpty(contentType))
{
Expand All @@ -109,11 +111,21 @@ private async Task InitializeAppConfigSessionAsync()

return contentType switch
{
"application/json" when _profile.IsFeatureFlag => FeatureFlagsProfileParser.Parse(stream),
"application/json" => JsonConfigurationParser.Parse(stream),
"application/x-yaml" => YamlConfigurationParser.Parse(stream),
_ => throw new FormatException($"This configuration provider does not support: {contentType ?? "Unknown"}")
};
}

public void Dispose() => _reloadChangeToken?.Dispose();

public override string ToString()
{
var className = GetType().Name;
var profile = $"{_profile.ApplicationId}:{_profile.EnvironmentId}:{_profile.ProfileId}:{_profile.ReloadAfter}";
var isFeatureFlag = _profile.IsFeatureFlag ? " (Feature Flag)" : string.Empty;

return $"{className} - {profile}{isFeatureFlag}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,36 +16,41 @@ public static class AppConfigConfigurationProviderExtensions
public static IConfigurationBuilder AddAppConfig(
this IConfigurationBuilder builder,
string sectionName = DefaultSectionName
)
{
foreach (var profile in LoadProfiles(builder, sectionName))
{
builder.Add(new AppConfigConfigurationSource(profile));
}

return builder;
}
) => AddAppConfigInternal(builder, null, sectionName);

public static IConfigurationBuilder AddAppConfig(
this IConfigurationBuilder builder,
IAmazonAppConfigData client,
string sectionName = DefaultSectionName
)
{
foreach (var profile in LoadProfiles(builder, sectionName))
{
builder.Add(new AppConfigConfigurationSource(client, profile));
}
) => AddAppConfigInternal(builder, client, sectionName);

return builder;
}

private static IEnumerable<AppConfigProfile> LoadProfiles(IConfigurationBuilder builder, string sectionName)
private static IConfigurationBuilder AddAppConfigInternal(
this IConfigurationBuilder builder,
IAmazonAppConfigData? client = null,
string sectionName = DefaultSectionName
)
{
var options = builder.Build()
.GetSection(sectionName)
.Get<AppConfigOptions>() ?? new AppConfigOptions();

return options.Profiles.Select(p => AppConfigProfileParser.Parse(p, options.Defaults.ReloadAfter));
var profiles = options.Profiles.Select(p =>
AppConfigProfileParser.Parse(p, false, options.Defaults.ReloadAfter)
);

var featureFlagProfiles = options.FeatureFlags.Select(p =>
AppConfigProfileParser.Parse(p, true, options.Defaults.ReloadAfter)
);

foreach (var profile in profiles.Concat(featureFlagProfiles))
{
builder.Add(
client is null
? new AppConfigConfigurationSource(profile)
: new AppConfigConfigurationSource(client, profile)
);
}

return builder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ public class AppConfigOptions
{
public List<string> Profiles { get; set; } = new();

public List<string> FeatureFlags { get; set; } = new();

public Defaults Defaults { get; set; } = new();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ namespace CatConsult.AppConfigConfigurationProvider;

public record AppConfigProfile(string ApplicationId, string EnvironmentId, string ProfileId)
{
public bool IsFeatureFlag { get; set; }

public int? ReloadAfter { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<ItemGroup>
<PackageReference Include="AWSSDK.AppConfigData" Version="3.7.300.16" />
<PackageReference Include="CatConsult.ConfigurationParsers" Version="2.1.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace CatConsult.AppConfigConfigurationProvider;

public class FeatureFlagsProfile : Dictionary<string, FeatureFlag>
{
}

public class FeatureFlag
{
public bool Enabled { get; set; }

public string? RequirementType { get; set; }

[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtraProperties { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ internal static class AppConfigProfileParser
private const string ProfileStringFormat = "ApplicationId:EnvironmentId:ProfileId[:ReloadAfter]";
private const string ProfileStringPattern = @"([a-z0-9]{7}):([a-z0-9]{7}):([a-z0-9]{7}):?(\d+)?";

public static AppConfigProfile Parse(string profileString, int defaultReloadAfter)
public static AppConfigProfile Parse(string profileString, bool isFeatureFlag, int defaultReloadAfter)
{
var match = Regex.Match(profileString, ProfileStringPattern);

Expand All @@ -26,6 +26,7 @@ public static AppConfigProfile Parse(string profileString, int defaultReloadAfte
var profile = new AppConfigProfile(applicationId, environmentId, profileId)
{
ReloadAfter = defaultReloadAfter,
IsFeatureFlag = isFeatureFlag,
};

if (int.TryParse(reloadAfterString, out var reloadAfter))
Expand Down
Loading

0 comments on commit b274580

Please sign in to comment.