Skip to content

Commit

Permalink
Created FeatureFlagging services, APIs and source generator, and Flag…
Browse files Browse the repository at this point in the history
…smith adapter, with stubs, and docs. #4
  • Loading branch information
jezzsantos committed Feb 18, 2024
1 parent 004141b commit 0f7dd65
Show file tree
Hide file tree
Showing 99 changed files with 3,332 additions and 77 deletions.
8 changes: 8 additions & 0 deletions README_DERIVATIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ Now, test that LocalStack works by running: `localstack start`

> When testing, Docker will need to be running for LocalStack to be used

### External Adapter Testing

> You only need to perform this step once, prior to running any of the `Integration.External` tests against 3rd party adapters (e.g., Flagsmith, Twillio, etc)

In the `Infrastructure.Shared.IntegrationTests` project, create a new file called `appsettings.Testing.local.json` and fill out the empty placeholders you see in `appsettings.TestingOnly.json` with values from service accounts that you have created for testing those 3rd party services.

> DO NOT add this file to source control!

# Build & Deploy

When pushed, all branches will be built and tested with GitHub actions
Expand Down
248 changes: 247 additions & 1 deletion docs/design-principles/0080-ports-and-adapters.md

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions docs/design-principles/0120-feature-flagging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Feature Flagging

## Design Principles

* We want to be able to deploy code which includes features/code that we dont want visible/available/enabled for end users.
* We want to be able to progressively roll-out certain features to specific users, or segments of the market to manage any risk of deploying new features
* This optionality can be attributed to all end-users, specific end-users, or a specific end-user, or even all users within a specific tenant
* We want those features to be configured externally to the running system, without changing what has been deployed
* We want to have those flags managed separately to our system, so that we don't have to build this kind of infrastructure ourselves

## Implementation

We have provided a service called `IFeatureFlags` that is available in any component of the architecture.

> That service will be implemented by an adapter to a 3rd party external system such as FlagSmith, GitLab, LaunchDarkly etc.
We have also provided an API to access this capability from the BEFFE, so flags can be shared in the Frontend JS app.

The interface `IFeatureFlags` provides methods to query flags in the system, using pre-defined flags in the code, that should be represented in the 3rd party system.

For example,

```c#
public class MyClass
{
private readonly IFeatureFlags _featureFlags;

public MyClass(IFeatureFlags featureFlags)
{
_featureFlags = featureFlags;
}

public void DoSomething()
{
if (_featureFlags.IsEnabled(Flag.MyFeature))
{
...do somethign with this feature
}
}
}
```

Where `MyFeature` is defined as a flag in `FeatureFlags.resx` file in the `Common` project.

### Defining flags

In code, flags are defined in the `FeatureFlags.resx` file in the `Common` project.

A source generator runs every build to translate those entries in the resource file to instances of the `Flags` class, to provide a typed collection of flags for use in code.

> This provides an easy way for intellisense to offer you the possible flags in the codebase to avoid using flags that no longer exist.
7 changes: 6 additions & 1 deletion src/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,9 @@ Desktop.ini
$RECYCLE.BIN/

# Mac desktop service store files
.DS_Store
.DS_Store

# Local configuration
local.settings.json
appsettings.local.json
appsettings.Testing.local.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/AWSLambdas.Api.WorkerHost/HostExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Application.Persistence.Shared.ReadModels;
using Common;
using Common.Configuration;
using Common.FeatureFlags;
using Common.Recording;
using Infrastructure.Common.Recording;
using Infrastructure.Hosting.Common;
Expand All @@ -24,6 +25,7 @@ public static void AddDependencies(this IServiceCollection services, IConfigurat
services.AddHttpClient();
services.AddSingleton<IConfigurationSettings>(new AspNetConfigurationSettings(configuration));
services.AddSingleton<IHostSettings, HostSettings>();
services.AddSingleton<IFeatureFlags, EmptyFeatureFlags>();

#if TESTINGONLY
services.AddSingleton<ICrashReporter>(new NullCrashReporter());
Expand Down
84 changes: 84 additions & 0 deletions src/AncillaryApplication.UnitTests/FeatureFlagsApplicationSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using Application.Interfaces;
using Common;
using Common.FeatureFlags;
using FluentAssertions;
using Moq;
using UnitTesting.Common;
using Xunit;

namespace AncillaryApplication.UnitTests;

[Trait("Category", "Unit")]
public class FeatureFlagsApplicationSpec
{
private readonly FeatureFlagsApplication _application;
private readonly Mock<ICallerContext> _caller;
private readonly Mock<IFeatureFlags> _featuresService;

public FeatureFlagsApplicationSpec()
{
var recorder = new Mock<IRecorder>();
_caller = new Mock<ICallerContext>();
_caller.Setup(cc => cc.CallerId).Returns("acallerid");
_caller.Setup(cc => cc.TenantId).Returns("atenantid");
_featuresService = new Mock<IFeatureFlags>();
_featuresService.Setup(fs => fs.GetFlagAsync(It.IsAny<Flag>(), It.IsAny<Optional<string>>(),
It.IsAny<Optional<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FeatureFlag
{
Name = "aname",
IsEnabled = true
});
_application = new FeatureFlagsApplication(recorder.Object, _featuresService.Object);
}

[Fact]
public async Task WhenGetFeatureFlag_ThenReturns()
{
var result =
await _application.GetFeatureFlagAsync(_caller.Object, "aname", null, "auserid", CancellationToken.None);

result.Should().BeSuccess();
result.Value.Name.Should().Be("aname");
result.Value.IsEnabled.Should().BeTrue();
_featuresService.Verify(fs => fs.GetFlagAsync(It.Is<Flag>(flag => flag.Name == "aname"), Optional<string>.None,
"auserid", It.IsAny<CancellationToken>()));
}

[Fact]
public async Task WhenGetFeatureFlagForCaller_ThenReturns()
{
var result =
await _application.GetFeatureFlagForCallerAsync(_caller.Object, "aname", CancellationToken.None);

result.Should().BeSuccess();
result.Value.Name.Should().Be("aname");
result.Value.IsEnabled.Should().BeTrue();
_featuresService.Verify(fs => fs.GetFlagAsync(It.Is<Flag>(flag =>
flag.Name == "aname"
), "atenantid", "acallerid", It.IsAny<CancellationToken>()));
}

[Fact]
public async Task WhenGetAllFeatureFlags_ThenReturns()
{
_featuresService.Setup(fs => fs.GetAllFlagsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<FeatureFlag>
{
new()
{
Name = "aname",
IsEnabled = true
}
});

var result =
await _application.GetAllFeatureFlagsAsync(_caller.Object, CancellationToken.None);

result.Should().BeSuccess();
result.Value.Count.Should().Be(1);
result.Value[0].Name.Should().Be("aname");
result.Value[0].IsEnabled.Should().BeTrue();
_featuresService.Verify(fs => fs.GetAllFlagsAsync(It.IsAny<CancellationToken>()));
}
}
20 changes: 18 additions & 2 deletions src/AncillaryApplication/FeatureFlagsApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,32 @@ public async Task<Result<List<FeatureFlag>, Error>> GetAllFeatureFlagsAsync(ICal
return flags.Value.ToList();
}

public async Task<Result<FeatureFlag, Error>> GetFeatureFlagForCallerAsync(ICallerContext context, string name,
CancellationToken cancellationToken)
{
var flag = await _featureFlags.GetFlagAsync(new Flag(name), context, cancellationToken);
if (!flag.IsSuccessful)
{
return flag.Error;
}

_recorder.TraceInformation(context.ToCall(),
"Feature flag {Name} was retrieved for user {User} in tenant {Tenant}", name, context.CallerId,
context.TenantId ?? "none");

return flag.Value;
}

public async Task<Result<FeatureFlag, Error>> GetFeatureFlagAsync(ICallerContext context, string name,
string? tenantId, string? userId, CancellationToken cancellationToken)
string? tenantId, string userId, CancellationToken cancellationToken)
{
var flag = await _featureFlags.GetFlagAsync(new Flag(name), tenantId, userId, cancellationToken);
if (!flag.IsSuccessful)
{
return flag.Error;
}

_recorder.TraceInformation(context.ToCall(), "Feature flag {Name} was retrieved", name);
_recorder.TraceInformation(context.ToCall(), "Feature flag {Name} was retrieved for user {User}", name, userId);

return flag.Value;
}
Expand Down
17 changes: 17 additions & 0 deletions src/AncillaryApplication/IFeatureFlagsApplication.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Application.Interfaces;
using Common;
using Common.FeatureFlags;

namespace AncillaryApplication;

public interface IFeatureFlagsApplication
{
Task<Result<List<FeatureFlag>, Error>> GetAllFeatureFlagsAsync(ICallerContext context,
CancellationToken cancellationToken);

Task<Result<FeatureFlag, Error>> GetFeatureFlagAsync(ICallerContext context, string name, string? tenantId,
string userId, CancellationToken cancellationToken);

Task<Result<FeatureFlag, Error>> GetFeatureFlagForCallerAsync(ICallerContext context, string name,
CancellationToken cancellationToken);
}
2 changes: 1 addition & 1 deletion src/AncillaryDomain.UnitTests/EmailDeliverRootSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ public void WhenSucceededDelivery_ThenDelivered()
root.Delivered.Should().BeNear(DateTime.UtcNow);
root.Events.Last().Should().BeOfType<Events.EmailDelivery.DeliverySucceeded>();
}

private static QueuedMessageId CreateMessageId()
{
var messageId = new MessageQueueIdFactory().Create("aqueuename");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Net;
using ApiHost1;
using Common.FeatureFlags;
using FluentAssertions;
using Infrastructure.Web.Api.Common.Extensions;
using Infrastructure.Web.Api.Operations.Shared.Ancillary;
using IntegrationTesting.WebApi.Common;
using IntegrationTesting.WebApi.Common.Stubs;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Task = System.Threading.Tasks.Task;

namespace AncillaryInfrastructure.IntegrationTests;

[Trait("Category", "Integration.Web")]
[Collection("API")]
public class FeatureFlagsApiSpec : WebApiSpec<Program>
{
private readonly StubFeatureFlags _featureFlags;

public FeatureFlagsApiSpec(WebApiSetup<Program> setup) : base(setup, OverrideDependencies)
{
EmptyAllRepositories();
_featureFlags = setup.GetRequiredService<IFeatureFlags>().As<StubFeatureFlags>();
_featureFlags.Reset();
}

[Fact]
public async Task WhenGetAllFeatureFlags_ThenReturnsFlags()
{
#if TESTINGONLY
var request = new GetAllFeatureFlagsRequest();

var result = await Api.GetAsync(request, req => req.SetHMACAuth(request, "asecret"));

result.StatusCode.Should().Be(HttpStatusCode.OK);
result.Content.Value.Flags.Count.Should().Be(0);
#endif
}

[Fact]
public async Task WhenGetFeatureFlag_ThenReturnsFlag()
{
#if TESTINGONLY
var request = new GetFeatureFlagForCallerRequest
{
Name = Flag.TestingOnly.Name
};

var result = await Api.GetAsync(request, req => req.SetHMACAuth(request, "asecret"));

result.StatusCode.Should().Be(HttpStatusCode.OK);
result.Content.Value.Flag!.Name.Should().Be(Flag.TestingOnly.Name);
_featureFlags.LastGetFlag.Should().Be(Flag.TestingOnly.Name);
#endif
}

private static void OverrideDependencies(IServiceCollection services)
{
// nothing here yet
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using AncillaryInfrastructure.Api.FeatureFlags;
using FluentAssertions;
using FluentValidation;
using Infrastructure.Web.Api.Operations.Shared.Ancillary;
using UnitTesting.Common.Validation;
using Xunit;

namespace AncillaryInfrastructure.UnitTests.Api.FeatureFlags;

[Trait("Category", "Unit")]
public class GetFeatureFlagForCallerRequestValidatorSpec
{
private readonly GetFeatureFlagForCallerRequest _dto;
private readonly GetFeatureFlagForCallerRequestValidator _validator;

public GetFeatureFlagForCallerRequestValidatorSpec()
{
_validator = new GetFeatureFlagForCallerRequestValidator();
_dto = new GetFeatureFlagForCallerRequest
{
Name = "aname"
};
}

[Fact]
public void WhenAllProperties_ThenSucceeds()
{
_validator.ValidateAndThrow(_dto);
}

[Fact]
public void WhenNameIsEmpty_ThenThrows()
{
_dto.Name = string.Empty;

_validator.Invoking(x => x.ValidateAndThrow(_dto))
.Should().Throw<ValidationException>()
.WithMessageLike(Resources.GetFeatureFlagRequestValidator_InvalidName);
}
}
Loading

0 comments on commit 0f7dd65

Please sign in to comment.