diff --git a/.pipelines/pipeline.user.windows.yml b/.pipelines/pipeline.user.windows.yml index 28c87dcb..4750d66c 100644 --- a/.pipelines/pipeline.user.windows.yml +++ b/.pipelines/pipeline.user.windows.yml @@ -7,16 +7,21 @@ environment: restore: commands: + - !!defaultcommand + name: 'Install .NET' + command: 'build/CallPowerShell.cmd' + arguments: 'build/install-dotnet.ps1 -RestoreOnly' - !!defaultcommand name: 'Restore' - command: 'build.cmd' - arguments: '-RestoreOnly' + command: 'build/CallPowerShell.cmd' + arguments: 'build.ps1 -RestoreOnly' build: commands: - !!buildcommand name: 'Dotnet Build' - command: 'build.cmd' + command: 'build/CallPowerShell.cmd' + arguments: 'build.ps1' logs: - from: 'buildlogs' to: 'Build Logs' @@ -32,7 +37,8 @@ package: commands: - !!buildcommand name: 'Dotnet Pack' - command: 'pack.cmd' + command: 'build/CallPowerShell.cmd' + arguments: 'pack.ps1' logs: - from: 'buildlogs' to: 'Build Logs' @@ -48,7 +54,8 @@ test: commands: - !!testcommand name: 'Dotnet Test' - command: 'test.cmd' + command: 'build/CallPowerShell.cmd' + arguments: 'test.ps1' fail_on_stderr: false testresults: - title: 'Unit Tests' diff --git a/Microsoft.FeatureManagement.sln b/Microsoft.FeatureManagement.sln index d4c7ce05..ed4c7d34 100644 --- a/Microsoft.FeatureManagement.sln +++ b/Microsoft.FeatureManagement.sln @@ -19,6 +19,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleApp", "examples\Cons EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TargetingConsoleApp", "examples\TargetingConsoleApp\TargetingConsoleApp.csproj", "{6558C21E-CF20-4278-AA08-EB9D1DF29D66}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CustomAssignmentConsoleApp", "examples\CustomAssignmentConsoleApp\CustomAssignmentConsoleApp.csproj", "{06C10E31-4C33-4567-85DB-00056A2BB511}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RazorPages", "examples\RazorPages\RazorPages.csproj", "{BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}" EndProject Global @@ -51,6 +53,10 @@ Global {6558C21E-CF20-4278-AA08-EB9D1DF29D66}.Debug|Any CPU.Build.0 = Debug|Any CPU {6558C21E-CF20-4278-AA08-EB9D1DF29D66}.Release|Any CPU.ActiveCfg = Release|Any CPU {6558C21E-CF20-4278-AA08-EB9D1DF29D66}.Release|Any CPU.Build.0 = Release|Any CPU + {06C10E31-4C33-4567-85DB-00056A2BB511}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06C10E31-4C33-4567-85DB-00056A2BB511}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06C10E31-4C33-4567-85DB-00056A2BB511}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06C10E31-4C33-4567-85DB-00056A2BB511}.Release|Any CPU.Build.0 = Release|Any CPU {BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}.Debug|Any CPU.Build.0 = Debug|Any CPU {BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -64,6 +70,7 @@ Global {FDBB27BA-C5BA-48A7-BA9B-63159943EA9F} = {8ED6FFEE-4037-49A2-9709-BC519C104A90} {E50FB931-7A42-440E-AC47-B8DFE5E15394} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} {6558C21E-CF20-4278-AA08-EB9D1DF29D66} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} + {06C10E31-4C33-4567-85DB-00056A2BB511} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} {BA29A1BB-81D5-4EB1-AF37-6ECF64AF27E2} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/README.md b/README.md index ab19e8c7..c76dd9c2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ASP.NET Core Feature Flags +# .NET Feature Management -Feature flags provide a way for ASP.NET Core applications to turn features on or off dynamically. Developers can use feature flags in simple use cases like conditional statements to more advanced scenarios like conditionally adding routes or MVC filters. Feature flags build on top of the .NET Core configuration system. Any .NET Core configuration provider is capable of acting as the back-bone for feature flags. +The Microsoft.FeatureManagement library enables developers to use feature flags and dynamic features inside of their applications. Feature flags can be used to turn features on or off dynamically. Developers can use feature flags in simple use cases like conditional statements to more advanced scenarios like conditionally adding routes or MVC filters. Dynamic features can be used to select different variants of a feature's configuration. This enables the possibility of using one version of a feature for one set of users, and another version of the feature for the remaining users. Here are some of the benefits of using this library: @@ -8,11 +8,11 @@ Here are some of the benefits of using this library: * Low barrier-to-entry * Built on `IConfiguration` * Supports JSON file feature flag setup -* Feature Flag lifetime management +* Feature flag lifetime management * Configuration values can change in real-time, feature flags can be consistent across the entire request -* Simple to Complex Scenarios Covered +* Simple to complex scenarios covered * Toggle on/off features through declarative configuration file - * Dynamically evaluate state of feature based on call to server + * Use different variants of a feature in different circumstances * API extensions for ASP.NET Core and MVC framework * Routing * Filters @@ -20,91 +20,82 @@ Here are some of the benefits of using this library: **API Reference**: https://go.microsoft.com/fwlink/?linkid=2091700 -### Feature Flags -Feature flags are composed of two parts, a name and a list of feature-filters that are used to turn the feature on. +## Content Version -### Feature Filters -Feature filters define a scenario for when a feature should be enabled. When a feature is evaluated for whether it is on or off, its list of feature-filters are traversed until one of the filters decides the feature should be enabled. At this point the feature is considered enabled and traversal through the feature filters stops. If no feature filter indicates that the feature should be enabled, then it will be considered disabled. +The content of this README relates to version 3 of the Microsoft.FeatureManagement library. -As an example, a Microsoft Edge browser feature filter could be designed. This feature filter would activate any features it is attached to as long as an HTTP request is coming from Microsoft Edge. +The README for version 2 can be found [here](https://github.com/microsoft/FeatureManagement-Dotnet/tree/release/v2#aspnet-core-feature-flags). + +## Index +* [Feature Flags](./README.md#Feature-Flags) + * [Feature Flag Declaration](./README.md#Feature-Flag-Declaration) + * [Feature Filters](./README.md#Feature-Filters) + * [ASP.NET Core Integration](./README.md#ASPNET-Core-Integration) + * [Built-in Feature Filters](./README.md#Built-in-Feature-Filters) +* [Dynamic Features](./README.md#Dynamic-Features) + * [Dynamic Feature Declaration](./README.md#Dynamic-Feature-Declaration) + * [Feature Variant Assigners](./README.md#Feature-Variant-Assigners) + * [Built-in Feature Variant Assigners](./README.md#Built-in-Feature-Variant-Assigners) +* [Targeting](./README.md#Targeting) +* [Caching](./README.md#Caching) +* [Custom Feature Providers](./README.md#Custom-Feature-Providers) + +## Feature Flags +Feature flags can either be on or off. They are composed of two parts, a name and a list of feature-filters that are used to turn the feature on. -## Registration +### Feature Flag Configuration -The .NET Core configuration system is used to determine the state of feature flags. The foundation of this system is `IConfiguration`. Any provider for IConfiguration can be used as the feature state provider for the feature flag library. This enables scenarios ranging from appsettings.json to Azure App Configuration and more. +The .NET Core configuration system is used to determine the state of features. The foundation of this system is `IConfiguration`. Any provider for IConfiguration can be used as the feature state provider for the feature management library. This enables scenarios ranging from appsettings.json to Azure App Configuration and more. ### Feature Flag Declaration -The feature management library supports appsettings.json as a feature flag source since it is a provider for .NET Core's IConfiguration system. Below we have an example of the format used to set up feature flags in a json file. +The feature management library supports appsettings.json as a feature flag source since it is a provider for .NET Core's IConfiguration system. Below we have an example of the format used to set up feature flags in a json file. The example below uses the 2.0.0 configuration schema which is supported in Microsoft.FeatureManagement version 3. For previous schema versions see the configuration [schema details](./docs/schemas/README.md). ``` JavaScript { - "Logging": { - "LogLevel": { - "Default": "Warning" - } - }, - - // Define feature flags in a json file + // Define feature flags in a json configuration file "FeatureManagement": { - "FeatureT": { - "EnabledFor": [ - { - "Name": "AlwaysOn" - } - ] - }, - "FeatureU": { - "EnabledFor": [] - }, - "FeatureV": { - "EnabledFor": [ - { - "Name": "TimeWindow", - "Parameters": { - "Start": "Wed, 01 May 2019 13:59:59 GMT", - "End": "Mon, 01 July 2019 00:00:00 GMT" + "FeatureFlags": { + "FeatureT": { + "EnabledFor": [ + { + "Name": "AlwaysOn" } - } - ] + ] + }, + "FeatureU": { + "EnabledFor": [ + { + "Name": "TimeWindow", + "Parameters": { + "Start": "Wed, 01 May 2019 13:59:59 GMT", + "End": "Mon, 01 July 2019 00:00:00 GMT" + } + } + ] + } } } } ``` -The `FeatureManagement` section of the json document is used by convention to load feature flag settings. In the section above, we see that we have provided three different features. Features define their feature filters using the `EnabledFor` property. In the feature filters for `FeatureT` we see `AlwaysOn`. This feature filter is built-in and if specified will always enable the feature. The `AlwaysOn` feature filter does not require any configuration so it only has the _Name_ property. `FeatureU` has no filters in its `EnabledFor` property and thus will never be enabled. Any functionality that relies on this feature being enabled will not be accessible as long as the feature filters remain empty. However, as soon as a feature filter is added that enables the feature it can begin working. `FeatureV` specifies a feature filter named `TimeWindow`. This is an example of a configurable feature filter. We can see in the example that the filter has a parameter's property. This is used to configure the filter. In this case, the start and end times for the feature to be active are configured. +The `FeatureManagement` section of the json document is used by convention to load feature flag settings. In the section above, we see that we have provided two different features. Features define their feature filters using the `EnabledFor` property. In the feature filters for `FeatureT` we see `AlwaysOn`. This feature filter is built-in and if specified will always enable the feature. The `AlwaysOn` feature filter does not require any configuration so it only has the _Name_ property. `FeatureU` specifies a feature filter named `TimeWindow`. This is an example of a configurable feature filter. We can see in the example that the filter has a parameter's property. This is used to configure the filter. In this case, the start and end times for the feature to be active are configured. ### On/Off Declaration The following snippet demonstrates an alternative way to define a feature that can be used for on/off features. ``` JavaScript { - "Logging": { - "LogLevel": { - "Default": "Warning" - } - }, - - // Define feature flags in config file + // Define feature flags in a json configuration file "FeatureManagement": { - "FeatureT": true, // On feature - "FeatureX": false // Off feature + "FeatureFlags": { + "FeatureT": true, // On feature + "FeatureX": false // Off feature + } } } ``` -### Referencing -To make it easier to reference these feature flags in code, we recommend to define feature flag variables like below. - -``` C# -// Define feature flags in an enum -public enum MyFeatureFlags -{ - FeatureT, - FeatureU, - FeatureV -} -``` - ### Service Registration Feature flags rely on .NET Core dependency injection. We can register the feature management services using standard conventions. @@ -115,12 +106,12 @@ using Microsoft.FeatureManagement.FeatureFilters; public class Startup { - public void ConfigureServices(IServiceCollection services) - { - services.AddFeatureManagement() - .AddFeatureFilter() - .AddFeatureFilter(); - } + public void ConfigureServices(IServiceCollection services) + { + services.AddFeatureManagement() + .AddFeatureFilter() + .AddFeatureFilter(); + } } ``` @@ -139,7 +130,7 @@ The basic form of feature management is checking if a feature is enabled and the … IFeatureManager featureManager; … -if (await featureManager.IsEnabledAsync(nameof(MyFeatureFlags.FeatureU))) +if (await featureManager.IsEnabledAsync("FeatureU")) { // Do something } @@ -161,11 +152,15 @@ public class HomeController : Controller } ``` +## ASP.NET Core Integration + +The feature management library provides functionality in ASP.NET Core and MVC to enable common feature flag scenarios in web applications. These capabilities are available by referencing the [Microsoft.FeatureManagement.AspNetCore](https://www.nuget.org/packages/Microsoft.FeatureManagement.AspNetCore/) NuGet package. + ### Controllers and Actions -MVC controller and actions can require that a given feature, or one of any list of features, be enabled in order to execute. This can be done by using a `FeatureGateAttribute`, which can be found in the `Microsoft.FeatureManagement.Mvc` namespace. +MVC controller and actions can require that a given feature flag, or one of any list of feature flags, be enabled in order to execute. This can be done by using a `FeatureGateAttribute`, which can be found in the `Microsoft.FeatureManagement.Mvc` namespace. ``` C# -[FeatureGate(MyFeatureFlags.FeatureX)] +[FeatureGate("FeatureX")] public class HomeController : Controller { … @@ -175,7 +170,7 @@ public class HomeController : Controller The `HomeController` above is gated by "FeatureX". "FeatureX" must be enabled before any action the `HomeController` contains can be executed. ``` C# -[FeatureGate(MyFeatureFlags.FeatureY)] +[FeatureGate("FeatureY")] public IActionResult Index() { return View(); @@ -186,7 +181,7 @@ The `Index` MVC action above requires "FeatureY" to be enabled before it can exe ### Disabled Action Handling -When an MVC controller or action is blocked because none of the features it specifies are enabled, a registered `IDisabledFeaturesHandler` will be invoked. By default, a minimalistic handler is registered which returns HTTP 404. This can be overridden using the `IFeatureManagementBuilder` when registering feature flags. +When an MVC controller or action is blocked because none of the feature flags it specifies are enabled, a registered `IDisabledFeaturesHandler` will be invoked. By default, a minimalistic handler is registered which returns HTTP 404. This can be overridden using the `IFeatureManagementBuilder` when registering feature flags. ``` C# public interface IDisabledFeaturesHandler @@ -200,29 +195,38 @@ public interface IDisabledFeaturesHandler In MVC views `` tags can be used to conditionally render content based on whether a feature is enabled or not. ``` HTML+Razor - +

This can only be seen if 'FeatureX' is enabled.

``` The `` tag requires a tag helper to work. This can be done by adding the feature management tag helper to the _ViewImports.cshtml_ file. + ``` HTML+Razor @addTagHelper *, Microsoft.FeatureManagement.AspNetCore ``` +The feature tag can also be used to show content if a feature is disabled. This is done by using the `negate` attribute. + +``` HTML+Razor + +

This can only be seen if 'FeatureX' is disabled.

+
+``` + ### MVC Filters -MVC action filters can be set up to conditionally execute based on the state of a feature. This is done by registering MVC filters in a feature aware manner. +MVC action filters can be set up to conditionally execute based on the state of a feature flag. This is done by registering MVC filters in a feature flag aware manner. The feature management pipeline supports async MVC Action filters, which implement `IAsyncActionFilter`. ``` C# services.AddMvc(o => { - o.Filters.AddForFeature(nameof(MyFeatureFlags.FeatureV)); + o.Filters.AddForFeature("FeatureV"); }); ``` -The code above adds an MVC filter named `SomeMvcFilter`. This filter is only triggered within the MVC pipeline if the feature it specifies, "FeatureV", is enabled. +The code above adds an MVC filter named `SomeMvcFilter`. This filter is only triggered within the MVC pipeline if the feature flag it specifies, "FeatureV", is enabled. ### Razor Pages MVC Razor pages can require that a given feature, or one of any list of features, be enabled in order to execute. This can be done by using a `FeatureGateAttribute`, which can be found in the `Microsoft.FeatureManagement.Mvc` namespace. @@ -243,15 +247,15 @@ When used on Razor pages, the `FeatureGateAttribute` must be placed on the page ### Application building -The feature management library can be used to add application branches and middleware that execute conditionally based on feature state. +The feature management library can be used to add application branches and middleware that execute conditionally based on feature flag state. ``` C# -app.UseMiddlewareForFeature(nameof(MyFeatureFlags.FeatureU)); +app.UseMiddlewareForFeature("FeatureU"); ``` -With the above call, the application adds a middleware component that only appears in the request pipeline if the feature "FeatureU" is enabled. If the feature is enabled/disabled during runtime, the middleware pipeline can be changed dynamically. +With the above call, the application adds a middleware component that only appears in the request pipeline if the feature flag "FeatureU" is enabled. If the feature flag is enabled/disabled during runtime, the middleware pipeline can be changed dynamically. -This builds off the more generic capability to branch the entire application based on a feature. +This builds off the more generic capability to branch the entire application based on a feature flag. ``` C# app.UseForFeature(featureName, appBuilder => @@ -260,32 +264,38 @@ app.UseForFeature(featureName, appBuilder => }); ``` -## Implementing a Feature Filter +## Feature Filters -Creating a feature filter provides a way to enable features based on criteria that you define. To implement a feature filter, the `IFeatureFilter` interface must be implemented. `IFeatureFilter` has a single method named `EvaluateAsync`. When a feature specifies that it can be enabled for a feature filter, the `EvaluateAsync` method is called. If `EvaluateAsync` returns `true` it means the feature should be enabled. +Feature filters define a scenario for when a feature flag should be enabled. When a feature flag is evaluated for whether it is on or off, its list of feature-filters are traversed until one of the filters decides the feature flag should be enabled. At this point the feature flag is considered enabled and traversal through the feature filters stops. If no feature filter indicates that the feature flag should be enabled, then it will be considered disabled. + +As an example, a Microsoft Edge browser feature filter could be designed. This feature filter would activate any features it is attached to as long as an HTTP request is coming from Microsoft Edge. + +### Implementing a Feature Filter + +Creating a feature filter provides a way to enable feature flags based on criteria that you define. To implement a feature filter, the `IFeatureFilter` interface must be implemented. `IFeatureFilter` has a single method named `EvaluateAsync`. When a feature flag specifies that it can be enabled for a feature filter, the `EvaluateAsync` method is called. If `EvaluateAsync` returns `true` it means the feature flag should be enabled. Feature filters are registered by the `IFeatureManagementBuilder` when `AddFeatureManagement` is called. These feature filters have access to the services that exist within the service collection that was used to add feature flags. Dependency injection can be used to retrieve these services. ### Parameterized Feature Filters -Some feature filters require parameters to decide whether a feature should be turned on or not. For example a browser feature filter may turn on a feature for a certain set of browsers. It may be desired that Edge and Chrome browsers enable a feature, while Firefox does not. To do this a feature filter can be designed to expect parameters. These parameters would be specified in the feature configuration, and in code would be accessible via the `FeatureFilterEvaluationContext` parameter of `IFeatureFilter.EvaluateAsync`. +Some feature filters require parameters to decide whether a feature flag should be turned on or not. For example a browser feature filter may turn on a feature flag for a certain set of browsers. It may be desired that Edge and Chrome browsers enable a feature flag, while Firefox does not. To do this a feature filter can be designed to expect parameters. These parameters would be specified in the feature configuration, and in code would be accessible via the `FeatureFilterEvaluationContext` parameter of `IFeatureFilter.EvaluateAsync`. ``` C# public class FeatureFilterEvaluationContext { /// - /// The name of the feature being evaluated. + /// The name of the feature flag being evaluated. /// - public string FeatureName { get; set; } + public string FeaturFlagName { get; set; } /// - /// The settings provided for the feature filter to use when evaluating whether the feature should be enabled. + /// The settings provided for the feature filter to use when evaluating whether the feature flag should be enabled. /// public IConfiguration Parameters { get; set; } } ``` -`FeatureFilterEvaluationContext` has a property named `Parameters`. These parameters represent a raw configuration that the feature filter can use to decide how to evaluate whether the feature should be enabled or not. To use the browser feature filter as an example once again, the filter could use `Parameters` to extract a set of allowed browsers that would have been specified for the feature and then check if the request is being sent from one of those browsers. +`FeatureFilterEvaluationContext` has a property named `Parameters`. These parameters represent a raw configuration that the feature filter can use to decide how to evaluate whether the feature flag should be enabled or not. To use the browser feature filter as an example once again, the filter could use `Parameters` to extract a set of allowed browsers that would have been specified for the feature flag and then check if the request is being sent from one of those browsers. ``` C# [FilterAlias("Browser")] @@ -320,7 +330,7 @@ This can be overridden through the use of the `FilterAliasAttribute`. A feature ### Missing Feature Filters -If a feature is configured to be enabled for a specific feature filter and that feature filter hasn't been registered, then an exception will be thrown when the feature is evaluated. The exception can be disabled by using the feature management options. +If a feature flag is configured to be enabled for a specific feature filter and that feature filter hasn't been registered, then an exception will be thrown when the feature flag is evaluated. The exception can be disabled by using the feature management options. ``` C# services.Configure(options => @@ -331,7 +341,7 @@ services.Configure(options => ### Using HttpContext -Feature filters can evaluate whether a feature should be enabled based off the properties of an HTTP Request. This is performed by inspecting the HTTP Context. A feature filter can get a reference to the HTTP Context by obtaining an `IHttpContextAccessor` through dependency injection. +Feature filters can evaluate whether a feature flag should be enabled based off the properties of an HTTP Request. This is performed by inspecting the HTTP Context. A feature filter can get a reference to the HTTP Context by obtaining an `IHttpContextAccessor` through dependency injection. ``` C# public class BrowserFilter : IFeatureFilter @@ -358,7 +368,7 @@ public void ConfigureServices(IServiceCollection services) ## Providing a Context For Feature Evaluation -In console applications there is no ambient context such as `HttpContext` that feature filters can acquire and utilize to check if a feature should be on or off. In this case, applications need to provide an object representing a context into the feature management system for use by feature filters. This is done by using `IFeatureManager.IsEnabledAsync(string featureName, TContext appContext)`. The appContext object that is provided to the feature manager can be used by feature filters to evaluate the state of a feature. +In console applications there is no ambient context such as `HttpContext` that feature filters can acquire and utilize to check if a feature should be on or off. In this case, applications need to provide an object representing a context into the feature management system for use by feature filters. This is done by using `IFeatureManager.IsEnabledAsync(string featureFlagName, TContext appContext)`. The appContext object that is provided to the feature manager can be used by feature filters to evaluate the state of a feature flag. ``` C# MyAppContext context = new MyAppContext @@ -374,7 +384,7 @@ if (await featureManager.IsEnabledAsync(feature, context)) ### Contextual Feature Filters -Contextual feature filters implement the `IContextualFeatureFilter` interface. These special feature filters can take advantage of the context that is passed in when `IFeatureManager.IsEnabledAsync` is called. The `TContext` type parameter in `IContextualFeatureFilter` describes what context type the filter is capable of handling. This allows the developer of a contextual feature filter to describe what is required of those who wish to utilize it. Since every type is a descendant of object, a filter that implements `IContextualFeatureFilter` can be called for any provided context. To illustrate an example of a more specific contextual feature filter, consider a feature that is enabled if an account is in a configured list of enabled accounts. +Contextual feature filters implement the `IContextualFeatureFilter` interface. These special feature filters can take advantage of the context that is passed in when `IFeatureManager.IsEnabledAsync` is called. The `TContext` type parameter in `IContextualFeatureFilter` describes what context type the filter is capable of handling. This allows the developer of a contextual feature filter to describe what is required of those who wish to utilize it. Since every type is a descendant of object, a filter that implements `IContextualFeatureFilter` can be called for any provided context. To illustrate an example of a more specific contextual feature filter, consider a feature flag that is enabled if an account is in a configured list of enabled accounts. ``` C# public interface IAccountContext @@ -393,7 +403,7 @@ class AccountIdFilter : IContextualFeatureFilter } ``` -We can see that the `AccountIdFilter` requires an object that implements `IAccountContext` to be provided to be able to evalute the state of a feature. When using this feature filter, the caller needs to make sure that the passed in object implements `IAccountContext`. +We can see that the `AccountIdFilter` requires an object that implements `IAccountContext` to be provided to be able to evalute the state of a feature flag. When using this feature filter, the caller needs to make sure that the passed in object implements `IAccountContext`. **Note:** Only a single feature filter interface can be implemented by a single type. Trying to add a feature filter that implements more than a single feature filter interface will result in an `ArgumentException`. @@ -405,7 +415,7 @@ Each of the built-in feature filters have their own parameters. Here is the list #### Microsoft.Percentage -This filter provides the capability to enable a feature based on a set percentage. +This filter provides the capability to enable a feature flag based on a set percentage. ``` JavaScript "EnhancedPipeline": { @@ -422,7 +432,7 @@ This filter provides the capability to enable a feature based on a set percentag #### Microsoft.TimeWindow -This filter provides the capability to enable a feature based on a time window. If only `End` is specified, the feature will be considered on until that time. If only start is specified, the feature will be considered on at all points after that time. +This filter provides the capability to enable a feature flag based on a time window. If only `End` is specified, the feature flag will be considered on until that time. If only start is specified, the feature flag will be considered on at all points after that time. ``` JavaScript "EnhancedPipeline": { @@ -440,7 +450,7 @@ This filter provides the capability to enable a feature based on a time window. #### Microsoft.Targeting -This filter provides the capability to enable a feature for a target audience. An in-depth explanation of targeting is explained in the [targeting](./README.md#Targeting) section below. The filter parameters include an audience object which describes users, groups, and a default percentage of the user base that should have access to the feature. Each group object that is listed in the target audience must also specify what percentage of the group's members should have access. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will have the feature enabled. +This filter provides the capability to enable a feature flag for a target audience. An in-depth explanation of targeting is explained in the [targeting](./README.md#Targeting) section below. The filter parameters include an audience object which describes users, groups, and a default percentage of the user base that should have access to the feature flag. Each group object that is listed in the target audience must also specify what percentage of the group's members should have access. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will have the feature flag enabled. ``` JavaScript "EnhancedPipeline": { @@ -475,6 +485,254 @@ This filter provides the capability to enable a feature for a target audience. A All of the built-in feature filter alias' are in the 'Microsoft' feature filter namespace. This is to prevent conflicts with other feature filters that may share the same simple alias. The segments of a feature filter namespace are split by the '.' character. A feature filter can be referenced by its fully qualified alias such as 'Microsoft.Percentage' or by the last segment which in the case of 'Microsoft.Percentage' is 'Percentage'. +## Dynamic Features + +When new features are being added to an application there may come a time when a feature has multiple different proposed design options. A common pattern when this happens is to do some form of A/B testing. That is, provide a different version of the feature to different segments of the user base, and judge off user interaction which is better. The dynamic feature functionality contained in this library aims to proivde a simplistic, standardized method for developers to perform this form of A/B testing. + + + In the scenario above, the different proposals for the design of a feature are referred to as variants of the feature. The feature itself is referred to as a dynamic feature. The variants of a dynamic feature can have types ranging from object, to string, to integer and so on. There is no limit to the amount of variants a dynamic feature may have. A developer is free to choose what type should be returned when a variant of a dynamic feature is requested. They are also free to choose how many variants are available to select from. + +Each variant of a dynamic feature is associated with a different configuration of the feature. Additionally, each variant of a dynamic feature contains information describing under what circumstances the variant should be used. + +### Consumption + +Dynamic features are accessible through the `IDynamicFeatureManager` interface. + +``` C# +public interface IDynamicFeatureManager +{ + IAsyncEnumerable GetDynamicFeatureNamesAsync(CancellationToken cancellationToken = default); + + ValueTask GetVariantAsync(string dynamicFeature, CancellationToken cancellationToken = default); + + ValueTask GetVariantAsync(string dynamicFeature, TContext context, CancellationToken cancellationToken = default); +} +``` + +The dynamic feature manager performs a resolution process that takes the name of a feature and returns a strongly typed value to represent the variant's value. + +The following steps are performed during the retrieval of a dynamic feature's variant +1. Lookup the configuration of the specified dynamic feature to find the registered variants +2. Assign one of the registered variants to be used. +3. Resolve typed value based off of the assigned variant. + +The dynamic feature manager is made available by using the `AddFeatureManagement` call. Make sure to add any required feature variant assigners referenced by dynamic features within the application by using `AddFeatureVariantAssigner`. + +``` C# +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Assigners; + +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddFeatureManagement() + .AddFeatureVariantAssigner(); + } +} +``` + +### Usage Example + +One possible example of when variants may be used is in a web application when there is a desire to test different visuals. In the following examples a mock of how one might assign different variants of a web page background to their users is shown. + +``` C# +// +// Modify view based off multiple possible variants +model.BackgroundUrl = dynamicFeatureManager.GetVariantAsync("HomeBackground", cancellationToken); + +return View(model); +``` + +### Dynamic Feature Declaration + +Dynamic features can be configured in a configuration file similarly to feature flags. Instead of being defined in the `FeatureManagement:FeatureFlags` section, they are defined in the `FeatureManagement:DynamicFeatures` section. Additionally, dynamic features have the following properties. + +* Assigner: The assigner that should be used to select which variant should be used any time this feature is accessed. +* Variants: The different variants of the dynamic feature. + * Name: The name of the variant. + * Default: Whether the variant should be used if no variant could be explicitly assigned. One and only one default variant is required. + * ConfigurationReference: A reference to the configuration of the variant to be used as typed options in the application. + * AssignmentParameters: The parameters used in the assignment process to determine if this variant should be used. + +An example of a dynamic feature named "ShoppingCart" is shown below. + +``` JavaScript +{ + "FeatureManagement": + { + "DynamicFeatures": { + "ShoppingCart": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "Big", + "ConfigurationReference": "ShoppingCart:Big", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Alec" + ], + "Groups": [ + ] + } + } + }, + { + "Name": "Small", + "ConfigurationReference": "ShoppingCart:Small", + "AssignmentParameters": { + "Audience": { + "Users": [ + ], + "Groups": [ + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 30 + } + } + } + ] + } + } + }, + "ShoppingCart": { + "Big": { + "Size": 400, + "Color": "green" + }, + "Small": { + "Size": 150, + "Color": "gray" + } + } +} +``` + +In the example above we see the declaration of a dynamic feature in a json configuration file. The dynamic feature is defined in the `FeatureManagement:DynamicFeatures` section of configuration. The name of this dynamic feature is `ShoppingCart`. A dynamic feature must declare a feature variant assigner that should be used to select a variant when requested. In this case the built-in `Targeting` feature variant assigner is used. The dynamic feature has two different variants that are available to the application. One variant is named `Big` and the other is named `Small`. Each variant contains a configuration reference denoted by the `ConfigurationReference` property. The configuration reference is a pointer to a section of application configuration that contains the options that should be used for that variant. The variant also contains assignment parameters denoted by the `AssignmentParameters` property. The assignment parameters are used by the assigner associated with the dynamic feature. The assigner reads the assignment parameters at run time when a variant of the dynamic feature is requested to choose which variant should be returned. + +An application that is configured with this `ShoppingCart` dynamic feature may request the value of a variant of the feature at runtime through the use of `IDynamicFeatureManager.GetVariantAsync`. The dynamic feature uses targeting for [variant assignment](./README.md#Feature-Variant-Assignment) so each of the variants' assignment parameters specify a target audience that should receive the variant. For a walkthrough of how the targeting assigner would choose a variant in this scenario reference the [Microsoft.Targeting Assigner](./README.md#MicrosoftTargeting-Feature-Variant-Assigner) section. When the feature manager chooses one of the variants it resolves the value of the variant by resolving the configuration reference declared in the variant. The example above includes the configuration that is referenced by the `ConfigurationReference` of each variant. + +### Feature Variant Assigners +A feature variant assigner is a component that uses contextual information within an application to decide which feature variant should be chosen when a variant of a dynamic feature is requested. + +### Feature Variant Assignment + +When requesting the value of a dynamic feature, the feature manager needs to determine which variant to use. The act of choosing which of the variants to be used is called "assignment." A built-in method of assignment allows the variants of a dynamic feature to be assigned to segments of an application's audience. This is the same [targeting](./README.md#MicrosoftTargeting-Feature-Variant-Assigner) strategy used by the targeting feature filter. + +To perform assignments, the feature manager uses components known as feature variant assigners. Feature variant assigners choose which of the variants of a dynamic feature should be assigned when a dynamic feature is requested. Each variant of a dynamic feature defines assignment parameters so that when an assigner is invoked, the assigner can tell under which conditions each variant should be selected. It is possible that an assigner is unable to choose between the list of available variants based on the configured assignment parameters. In this case, the feature manager chooses the **default variant**. The default variant is a variant that is marked explicitly as the default. It is required to have a default variant when configuring a dynamic feature in order to handle the possibility that an assigner is not able to select a variant of a dynamic feature. + +### Custom Assignment + +There may come a time when custom criteria is needed to decide which variant of a feature should be assigned when a feature is referenced. This is made possible by an extensibility model that allows the act of assignment to be overridden. Every feature registered in the feature management system that uses feature variants specifies what assigner should be used to choose a variant. + + +``` C# + public interface IFeatureVariantAssigner : IFeatureVariantAssignerMetadata + { + /// + /// Assign a variant of a feature to be used based off of customized criteria. + /// + /// Information provided by the system to be used during the assignment process. + /// The cancellation token to cancel the operation. + /// The variant that should be assigned for a given feature. + ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken); + } +} +``` + +An example implementation can be found in [this example](./examples/CustomAssignmentConsoleApp/RecurringAssigner.cs). + +### Built-In Feature Variant Assigners + +There is a built-in feature variant assigner that uses targeting. It comes with the `Microsoft.FeatureManagement` package. This assigner is not added automatically, but it can be referenced and registered as soon as the package is registered. + +#### Microsoft.Targeting Feature Variant Assigner + +This feature variant assigner provides the capability to assign the variants of a dynamic feature to targeted audiences. An in-depth explanation of targeting is explained in the [targeting](./README.md#Targeting) section. + +The assignment parameters used by the targeting feature variant assigner include an audience object which describes the user base that should receive the associated variant. The audience is made of users, groups, and a percentage of the entire user base. Each group object that is listed in the target audience is required to specify what percentage of the group's members should have receive the variant. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will receive the associated variant. + +``` JavaScript +"ShoppingCart": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "Big", + "ConfigurationReference": "ShoppingCart:Big", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Alec" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ] + } + } + }, + { + "Name": "Small", + "ConfigurationReference": "ShoppingCart:Small", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Susan", + ], + "Groups": [ + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 80 + } + } + } + ] +} +``` + +Based on the configured audiences for the variants included in this feature, if the application is executed under the context of a user named `Alec` then the value of the `Big` variant will be returned. If the application is executing under the context of a user named `Susan` then the value of the `Small` variant will be returned. If a user match does not occur, then group matches are evaluated. If the application is executed under the context of a user in the group `Ring0` then the `Big` variant will be returned. If the user's group is `Ring1` instead, then the user has a 50% chance of being assigned to `Small`. If there is no user match nor group match, then the default rollout percentage is used. In this case, 80% of unmatched users will get the `Small` variant, leaving the other 20% to get the `Big` variant since it is marked as the `Default`. + +Example usage of this assigner can be found in the [FeatureFlagDemo example](./examples/FeatureFlagDemo/Startup.cs#L63). + +When using the targeting feature variant assigner, make sure to register it as well as an implementation of [ITargetingContextAccessor](./README.md#ITargetingContextAccessor). + +``` C# +services.AddSingleton(); + +services.AddFeatureManagement(); + .AddFeatureVariantAssigner(); +``` + +### Variant Resolution + +When a variant of a dynamic feature has been chosen, the feature management system resolves the configuration reference associated with that variant. The resolution is done through the `ConfigurationReference` property. In the "[Configuring a Dynamic Feature](./README.md#Configuring-a-Dynamic-Feature)" section we see a dynamic feature named `ShoppingCart`. The first variant of the feature, is named "Big", and is being referenced in the feature variant as `ShoppingCart:Big` in the configuration reference. The referenced section is shown below. + +``` Javascript + "ShoppingCart": { + "Big": { + "Size": 400, + "Color": "green" + } + } +``` + +The feature management system resolves the configuration reference and binds the resolved configuration section to the type specified when a variant of a dynamic feature is requested. This is performed by an implementation of the `IFeatureVariantOptionsResolver`. By providing a custom implementation of `IFeatureVariantOptionsResolver`, a developer can resolve configuration references from sources other than configuration. + ## Targeting Targeting is a feature management strategy that enables developers to progressively roll out new features to their user base. The strategy is built on the concept of targeting a set of users known as the target _audience_. An audience is made up of specific users, groups, and a designated percentage of the entire user base. The groups that are included in the audience can be broken down further into percentages of their total members. @@ -498,7 +756,7 @@ To begin using the `TargetingFilter` in an application it must be added to the a The implementation type used for the `ITargetingContextAccessor` service must be implemented by the application that is using the targeting filter. Here is an example setting up feature management in a web application to use the `TargetingFilter` with an implementation of `ITargetingContextAccessor` called `HttpContextTargetingContextAccessor`. -``` +``` C# services.AddSingleton(); services.AddFeatureManagement(); @@ -516,14 +774,14 @@ An example that extracts targeting context information from the application's HT The targeting filter relies on a targeting context to evaluate whether a feature should be turned on. This targeting context contains information such as what user is currently being evaluated, and what groups the user in. In console applications there is typically no ambient context available to flow this information in to the targeting filter, thus it must be passed directly when `FeatureManager.IsEnabledAsync` is called. This is supported through the use of the `ContextualTargetingFilter`. Applications that need to float the targeting context into the feature manager should use this instead of the `TargetingFilter.` -``` +``` C# services.AddFeatureManagement() .AddFeatureFilter(); ``` Since `ContextualTargetingFilter` is an [`IContextualTargetingFilter`](./README.md#Contextual-Feature-Filters), an implementation of `ITargetingContext` must be passed in to `IFeatureManager.IsEnabledAsync` for it to be able to evaluate and turn a feature on. -``` +``` C# IFeatureManager fm; … // userId and groups defined somewhere earlier in application @@ -544,42 +802,48 @@ An example that uses the `ContextualTargetingFilter` in a console application is Options are available to customize how targeting evaluation is performed across all features. These options can be configured when setting up feature management. -``` +``` C# services.Configure(options => { options.IgnoreCase = true; }); ``` +### Targeting in Dynamic Features + +The concept of targeting can be extended to dynamic features. Instead of targeting an audience to see a feature as enabled, the variants of a dynamic feature can be configured to target different audiences. For an in depth view of how this can be done see the [targeting feature variant assigner](./README.md#MicrosoftTargeting-Feature-Variant-Assigner) section. + ## Caching Feature state is provided by the IConfiguration system. Any caching and dynamic updating is expected to be handled by configuration providers. The feature manager asks IConfiguration for the latest value of a feature's state whenever a feature is checked to be enabled. ### Snapshot -There are scenarios which require the state of a feature to remain consistent during the lifetime of a request. The values returned from the standard `IFeatureManager` may change if the `IConfiguration` source which it is pulling from is updated during the request. This can be prevented by using `IFeatureManagerSnapshot`. `IFeatureManagerSnapshot` can be retrieved in the same manner as `IFeatureManager`. `IFeatureManagerSnapshot` implements the interface of `IFeatureManager`, but it caches the first evaluated state of a feature during a request and will return the same state of a feature during its lifetime. +There are scenarios which require the state of a feature to remain consistent during the lifetime of a request. The values returned from the standard `IFeatureManager` may change if the `IConfiguration` source which it is pulling from is updated during the request. This can be prevented by using `IFeatureManagerSnapshot`. `IFeatureManagerSnapshot` can be retrieved in the same manner as `IFeatureManager`. `IFeatureManagerSnapshot` implements the interface of `IFeatureManager`, but it caches the first evaluated state of a feature during a request and will return the same state of a feature during its lifetime. Symmetric functionality is available for dynamic features through the use of `IDynamicFeatureManagerSnapshot`. ## Custom Feature Providers Implementing a custom feature provider enable developers to pull feature flags from sources such as a database or a feature management service. The included feature provider that is used by default pulls feature flags from .NET Core's configuration system. This allows for features to be defined in an [appsettings.json](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1#jcp) file or in configuration providers like [Azure App Configuration](https://docs.microsoft.com/en-us/azure/azure-app-configuration/quickstart-feature-flag-aspnet-core?tabs=core2x). This behavior can be substituted to provide complete control of where feature definitions are read from. -To customize the loading of feature definitions, one must implement the `IFeatureDefinitionProvider` interface. +To customize the loading of feature flag definitions, one must implement the `IFeatureFlagDefinitionProvider` interface. -``` -public interface IFeatureDefinitionProvider +``` C# +public interface IFeatureFlagDefinitionProvider { - Task GetFeatureDefinitionAsync(string featureName); + Task GetFeatureFlagDefinitionAsync(string featureflagName, CancellationToken cancellationToken = default); - IAsyncEnumerable GetAllFeatureDefinitionsAsync(); + IAsyncEnumerable GetAllFeatureFlagDefinitionsAsync(CancellationToken cancellationToken = default); } ``` -To use an implementation of `IFeatureDefinitionProvider` it must be added into the service collection before adding feature management. The following example adds an implementation of `IFeatureDefinitionProvider` named `InMemoryFeatureDefinitionProvider`. +To use an implementation of `IFeatureFlagDefinitionProvider` it must be added into the service collection before adding feature management. The following example adds an implementation of `IFeatureFlagDefinitionProvider` named `InMemoryFeatureDefinitionProvider`. -``` -services.AddSingleton() +``` C# +services.AddSingleton() .AddFeatureManagement() ``` +It is also possible to provide custom dynamic feature definitions. This is done by implementing the `IDynamicFeatureDefinitionProvider` interface and registering it as mentioned above. + # Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a diff --git a/build.cmd b/build.cmd deleted file mode 100644 index df877382..00000000 --- a/build.cmd +++ /dev/null @@ -1,8 +0,0 @@ -call %~dp0build\ChoosePowerShell.cmd - -IF %ERRORLEVEL% NEQ 0 ( - - exit /B 1 -) - -%PowerShell% "%~dp0build.ps1" %* \ No newline at end of file diff --git a/build.ps1 b/build.ps1 index 02d792fd..cd90b5c9 100644 --- a/build.ps1 +++ b/build.ps1 @@ -26,15 +26,17 @@ if ((Test-Path -Path $LogDirectory) -ne $true) { New-Item -ItemType Directory -Path $LogDirectory | Write-Verbose } +$dotnet = & "$PSScriptRoot/build/resolve-dotnet.ps1" + if ($RestoreOnly) { # Restore - dotnet restore "$Solution" + & $dotnet restore "$Solution" } else { # Build - dotnet build -c $BuildConfig "$Solution" | Tee-Object -FilePath "$LogDirectory\build.log" + & $dotnet build -c $BuildConfig "$Solution" | Tee-Object -FilePath "$LogDirectory\build.log" } exit $LASTEXITCODE diff --git a/build/CallPowerShell.cmd b/build/CallPowerShell.cmd new file mode 100644 index 00000000..fd8eb280 --- /dev/null +++ b/build/CallPowerShell.cmd @@ -0,0 +1 @@ +PowerShell %~dp0..\%* \ No newline at end of file diff --git a/build/ChoosePowerShell.cmd b/build/ChoosePowerShell.cmd deleted file mode 100644 index 45798129..00000000 --- a/build/ChoosePowerShell.cmd +++ /dev/null @@ -1,23 +0,0 @@ -:: where.exe does not exist in windows container, application specific test must be used to check for existence - -pwsh -Command Write-Host "a" - -IF %ERRORLEVEL% == 0 ( - - set PowerShell=pwsh - - exit /B 0 -) - -PowerShell -Command Write-Host "a" - -IF %ERRORLEVEL% == 0 ( - - set PowerShell=PowerShell - - exit /B 0 -) - -echo Could not find a suitable PowerShell executable. - -EXIT /B 1 diff --git a/build/install-dotnet.ps1 b/build/install-dotnet.ps1 new file mode 100644 index 00000000..7088094f --- /dev/null +++ b/build/install-dotnet.ps1 @@ -0,0 +1,8 @@ +# Installs .NET Core 2.1 and .NET 6 for CI/CD environment +# see: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script#examples + +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; + +&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Version 2.1.816 + +&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) diff --git a/build/resolve-dotnet.ps1 b/build/resolve-dotnet.ps1 new file mode 100644 index 00000000..79219a8b --- /dev/null +++ b/build/resolve-dotnet.ps1 @@ -0,0 +1,14 @@ +# Resolves dotnet execution path +# Locations considered include dotnet install script default location and somewhere on path +$CI_CD_INSTALL_PATH = "$env:LOCALAPPDATA\Microsoft\dotnet\dotnet.exe" + +if (Test-Path $CI_CD_INSTALL_PATH) +{ + $CI_CD_INSTALL_PATH + + return +} + +$dotnet = Get-Command dotnet.exe -ErrorAction Stop + +$dotnet.Source \ No newline at end of file diff --git a/docs/schemas/FeatureManagement.v1.0.0.json b/docs/schemas/FeatureManagement.v1.0.0.json new file mode 100644 index 00000000..83f6a1df --- /dev/null +++ b/docs/schemas/FeatureManagement.v1.0.0.json @@ -0,0 +1,75 @@ +{ + "$schema":"http://json-schema.org/draft-07/schema#", + "$id":"http://azconfig.io/schemas/FeatureManagement-DotNet/v1.0.0/FeatureManagement.json", + "title":"Decalaration of features in the Microsoft.FeatureManagement library.", + "definitions": { + "FeatureFlag": { + "type":"object", + "properties": { + "EnabledFor": { + "type":"array", + "items": { + "type":"object", + "required":[ + "Name" + ], + "properties": { + "Name": { + "type":"string" + }, + "Parameters": { + "type":"object", + "patternProperties": { + "^.*$": { + "anyOf":[ + { + "type":"string" + }, + { + "type":"null" + }, + { + "type":"object" + }, + { + "type":"number" + }, + { + "type":"array" + }, + { + "type":"boolean" + } + ] + } + }, + "additionalProperties":false + } + } + } + } + } + } + }, + "type":"object", + "required":[ + ], + "properties": { + "FeatureManagement": { + "type":"object", + "patternProperties": { + "^.*$": { + "anyOf":[ + { + "$ref":"#/definitions/FeatureFlag" + }, + { + "type":"boolean" + } + ] + } + }, + "additionalProperties":false + } + } +} diff --git a/docs/schemas/FeatureManagement.v2.0.0.json b/docs/schemas/FeatureManagement.v2.0.0.json new file mode 100644 index 00000000..a01338b6 --- /dev/null +++ b/docs/schemas/FeatureManagement.v2.0.0.json @@ -0,0 +1,150 @@ +{ + "$schema":"http://json-schema.org/draft-07/schema#", + "$id":"http://azconfig.io/schemas/FeatureManagement-DotNet/v2.0.0/FeatureManagement.json", + "title":"Decalaration of features in the Microsoft.FeatureManagement library.", + "definitions": { + "FeatureFlag": { + "type":"object", + "properties": { + "EnabledFor": { + "type":"array", + "items": { + "type":"object", + "required":[ + "Name" + ], + "properties": { + "Name": { + "type":"string" + }, + "Parameters": { + "type":"object", + "patternProperties": { + "^.*$": { + "anyOf":[ + { + "type":"string" + }, + { + "type":"null" + }, + { + "type":"object" + }, + { + "type":"number" + }, + { + "type":"array" + }, + { + "type":"boolean" + } + ] + } + }, + "additionalProperties":false + } + } + } + } + } + }, + "DynamicFeature": { + "type":"object", + "required": [ + "Assigner", + "Variants" + ], + "properties": { + "Assigner": { + "type":"string" + }, + "Variants": { + "type":"array", + "items": { + "type":"object", + "required":[ + "Name", + "ConfigurationReference" + ], + "properties": { + "Default": { + "type":"boolean" + }, + "Name": { + "type":"string" + }, + "ConfigurationReference": { + "type":"string" + }, + "AssignmentParameters": { + "type":"object", + "patternProperties": { + "^.*$": { + "anyOf":[ + { + "type":"string" + }, + { + "type":"null" + }, + { + "type":"object" + }, + { + "type":"number" + }, + { + "type":"array" + }, + { + "type":"boolean" + } + ] + } + }, + "additionalProperties":false + } + } + } + } + } + } + }, + "type":"object", + "required":[ + ], + "properties": { + "FeatureManagement": { + "type":"object", + "properties": { + "FeatureFlags": { + "type":"object", + "patternProperties": { + "^.*$": { + "anyOf":[ + { + "$ref":"#/definitions/FeatureFlag" + }, + { + "type":"boolean" + } + ] + } + }, + "additionalProperties":false + }, + "DynamicFeatures": { + "type":"object", + "patternProperties": { + "^.*$": { + "$ref":"#/definitions/DynamicFeature" + } + }, + "additionalProperties":false + } + } + } + } +} diff --git a/docs/schemas/README.md b/docs/schemas/README.md new file mode 100644 index 00000000..2b94065e --- /dev/null +++ b/docs/schemas/README.md @@ -0,0 +1,17 @@ +# Configuration Schemas + +This folder contains the schemas for the configuration used by the Microsoft.FeatureManagement library. + +# 1.0.0 + +The [1.0.0 schema](./FeatureManagement.v1.0.0.json) is supported by Microsoft.FeatureManagement version 1.x - 3.x. + +* Allows feature flags to be defined. + +# 2.0.0 + +The [2.0.0 schema](./FeatureManagement.v2.0.0.json) is supported by Microsoft.FeatureManagement version 3.x. + +* Allows dynamic features to be defined. +* Uses a more explicit path to define feature flags. + * "FeatureManagement:FeatureFlags:{flagName}" instead of "FeatureManagement:{flagName}". diff --git a/examples/ConsoleApp/ConsoleApp.csproj b/examples/ConsoleApp/ConsoleApp.csproj index c638b298..1230fedf 100644 --- a/examples/ConsoleApp/ConsoleApp.csproj +++ b/examples/ConsoleApp/ConsoleApp.csproj @@ -2,13 +2,13 @@ Exe - net5.0 + net6.0 Consoto.Banking.AccountService - - + + diff --git a/examples/ConsoleApp/FeatureFilters/AccountIdFilter.cs b/examples/ConsoleApp/FeatureFilters/AccountIdFilter.cs index 1fd56d4a..193e87d4 100644 --- a/examples/ConsoleApp/FeatureFilters/AccountIdFilter.cs +++ b/examples/ConsoleApp/FeatureFilters/AccountIdFilter.cs @@ -6,6 +6,7 @@ using Microsoft.FeatureManagement; using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Consoto.Banking.AccountService.FeatureManagement @@ -17,7 +18,7 @@ namespace Consoto.Banking.AccountService.FeatureManagement [FilterAlias("AccountId")] class AccountIdFilter : IContextualFeatureFilter { - public Task EvaluateAsync(FeatureFilterEvaluationContext featureEvaluationContext, IAccountContext accountContext) + public Task EvaluateAsync(FeatureFilterEvaluationContext featureEvaluationContext, IAccountContext accountContext, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(accountContext?.AccountId)) { diff --git a/examples/ConsoleApp/Program.cs b/examples/ConsoleApp/Program.cs index e0ab1021..afe4c52f 100644 --- a/examples/ConsoleApp/Program.cs +++ b/examples/ConsoleApp/Program.cs @@ -8,6 +8,7 @@ using Microsoft.FeatureManagement.FeatureFilters; using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Consoto.Banking.AccountService @@ -58,7 +59,7 @@ public static async Task Main(string[] args) AccountId = account }; - bool enabled = await featureManager.IsEnabledAsync(FeatureName, accountServiceContext); + bool enabled = await featureManager.IsEnabledAsync(FeatureName, accountServiceContext, CancellationToken.None); // // Output results diff --git a/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj b/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj new file mode 100644 index 00000000..1230fedf --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/CustomAssignmentConsoleApp.csproj @@ -0,0 +1,24 @@ + + + + Exe + net6.0 + Consoto.Banking.AccountService + + + + + + + + + + + + + + Always + + + + diff --git a/examples/CustomAssignmentConsoleApp/DailyDiscountOptions.cs b/examples/CustomAssignmentConsoleApp/DailyDiscountOptions.cs new file mode 100644 index 00000000..71964e6f --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/DailyDiscountOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Consoto.Banking.HelpDesk +{ + class DailyDiscountOptions + { + public string ProductName { get; set; } + + public int Discount { get; set; } + } +} diff --git a/examples/CustomAssignmentConsoleApp/Program.cs b/examples/CustomAssignmentConsoleApp/Program.cs new file mode 100644 index 00000000..a9ec26d4 --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/Program.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Consoto.Banking.AccountService; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Consoto.Banking.HelpDesk +{ + class Program + { + public static async Task Main(string[] args) + { + // + // Setup configuration + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", false, true) + .Build(); + + // + // Setup application services + feature management + IServiceCollection services = new ServiceCollection(); + + services.AddSingleton(typeof(IFeatureVariantAssignerMetadata), typeof(RecurringAssigner)); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + + // + // Get the feature manager from application services + using (ServiceProvider serviceProvider = services.BuildServiceProvider()) + { + IDynamicFeatureManager dynamicFeatureManager = serviceProvider.GetRequiredService(); + + DailyDiscountOptions discountOptions = await dynamicFeatureManager + .GetVariantAsync("DailyDiscount", CancellationToken.None); + + // + // Output results + Console.WriteLine($"Today there is a {discountOptions.Discount}% discount on {discountOptions.ProductName}!"); + } + } + } +} diff --git a/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs b/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs new file mode 100644 index 00000000..3a6845df --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/RecurringAssigner.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.FeatureManagement; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Consoto.Banking.AccountService +{ + [AssignerAlias("Recurring")] + class RecurringAssigner : IFeatureVariantAssigner + { + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken _) + { + DynamicFeatureDefinition featureDefinition = variantAssignmentContext.FeatureDefinition; + + FeatureVariant chosenVariant = null; + + string currentDay = DateTimeOffset.UtcNow.DayOfWeek.ToString(); + + foreach (var variant in featureDefinition.Variants) + { + RecurringAssignmentParameters p = variant.AssignmentParameters.Get() ?? + new RecurringAssignmentParameters(); + + if (p.Days != null && + p.Days.Any(d => d.Equals(currentDay, StringComparison.OrdinalIgnoreCase))) + { + chosenVariant = variant; + + break; + } + } + + return new ValueTask(chosenVariant); + } + } +} diff --git a/examples/CustomAssignmentConsoleApp/RecurringAssignmentParameters.cs b/examples/CustomAssignmentConsoleApp/RecurringAssignmentParameters.cs new file mode 100644 index 00000000..34518abe --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/RecurringAssignmentParameters.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; + +namespace Consoto.Banking.AccountService +{ + class RecurringAssignmentParameters + { + public List Days { get; set; } + } +} diff --git a/examples/CustomAssignmentConsoleApp/appsettings.json b/examples/CustomAssignmentConsoleApp/appsettings.json new file mode 100644 index 00000000..8cbd7ee8 --- /dev/null +++ b/examples/CustomAssignmentConsoleApp/appsettings.json @@ -0,0 +1,33 @@ +{ + "FeatureManagement": { + "DailyDiscount": { + "Assigner": "Recurring", + "Variants": [ + { + "Default": true, + "Name": "Default", + "ConfigurationReference": "DailyDiscount:Default" + }, + { + "Name": "Special", + "ConfigurationReference": "DailyDiscount:Special", + "AssignmentParameters": { + "Days": [ + "Tuesday" + ] + } + } + ] + } + }, + "DailyDiscount": { + "Default": { + "Discount": 20, + "ProductName": "Bananas" + }, + "Special": { + "Discount": 30, + "ProductName": "Fish" + } + } +} \ No newline at end of file diff --git a/examples/FeatureFlagDemo/BrowserFilter.cs b/examples/FeatureFlagDemo/BrowserFilter.cs index efeb8e70..cd701078 100644 --- a/examples/FeatureFlagDemo/BrowserFilter.cs +++ b/examples/FeatureFlagDemo/BrowserFilter.cs @@ -6,6 +6,7 @@ using Microsoft.FeatureManagement; using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace FeatureFlagDemo.FeatureManagement.FeatureFilters @@ -23,7 +24,7 @@ public BrowserFilter(IHttpContextAccessor httpContextAccessor) _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); } - public Task EvaluateAsync(FeatureFilterEvaluationContext context) + public Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken) { BrowserFilterSettings settings = context.Parameters.Get() ?? new BrowserFilterSettings(); diff --git a/examples/FeatureFlagDemo/Controllers/HomeController.cs b/examples/FeatureFlagDemo/Controllers/HomeController.cs index c4363967..e6f47169 100644 --- a/examples/FeatureFlagDemo/Controllers/HomeController.cs +++ b/examples/FeatureFlagDemo/Controllers/HomeController.cs @@ -8,6 +8,7 @@ using Microsoft.FeatureManagement; using Microsoft.FeatureManagement.Mvc; using System.Threading.Tasks; +using System.Threading; namespace FeatureFlagDemo.Controllers { @@ -26,13 +27,13 @@ public IActionResult Index() return View(); } - public async Task About() + public async Task About(CancellationToken cancellationToken) { ViewData["Message"] = "Your application description page."; - if (await _featureManager.IsEnabledAsync(nameof(MyFeatureFlags.CustomViewData))) + if (await _featureManager.IsEnabledAsync(MyFeatureFlags.CustomViewData, cancellationToken)) { - ViewData["Message"] = $"This is FANCY CONTENT you can see only if '{nameof(MyFeatureFlags.CustomViewData)}' is enabled."; + ViewData["Message"] = $"This is FANCY CONTENT you can see only if '{MyFeatureFlags.CustomViewData}' is enabled."; }; return View(); diff --git a/examples/FeatureFlagDemo/DiscountBannerOptions.cs b/examples/FeatureFlagDemo/DiscountBannerOptions.cs new file mode 100644 index 00000000..a458b5b2 --- /dev/null +++ b/examples/FeatureFlagDemo/DiscountBannerOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace FeatureFlagDemo +{ + public class DiscountBannerOptions + { + public int Size { get; set; } + + public string Color { get; set; } + + public string Background { get; set; } + } +} diff --git a/examples/FeatureFlagDemo/FeatureFlagDemo.csproj b/examples/FeatureFlagDemo/FeatureFlagDemo.csproj index 3bf22dc9..9290c5e6 100644 --- a/examples/FeatureFlagDemo/FeatureFlagDemo.csproj +++ b/examples/FeatureFlagDemo/FeatureFlagDemo.csproj @@ -1,11 +1,11 @@  - net5.0 + net6.0 - + diff --git a/examples/FeatureFlagDemo/HttpContextTargetingContextAccessor.cs b/examples/FeatureFlagDemo/HttpContextTargetingContextAccessor.cs index 9f9c8964..097605b6 100644 --- a/examples/FeatureFlagDemo/HttpContextTargetingContextAccessor.cs +++ b/examples/FeatureFlagDemo/HttpContextTargetingContextAccessor.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Security.Claims; +using System.Threading; using System.Threading.Tasks; namespace FeatureFlagDemo @@ -23,7 +24,7 @@ public HttpContextTargetingContextAccessor(IHttpContextAccessor httpContextAcces _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); } - public ValueTask GetContextAsync() + public ValueTask GetContextAsync(CancellationToken cancellationToken) { HttpContext httpContext = _httpContextAccessor.HttpContext; diff --git a/examples/FeatureFlagDemo/MyFeatureFlags.cs b/examples/FeatureFlagDemo/MyFeatureFlags.cs index 9441c531..3fc1be99 100644 --- a/examples/FeatureFlagDemo/MyFeatureFlags.cs +++ b/examples/FeatureFlagDemo/MyFeatureFlags.cs @@ -3,14 +3,12 @@ // namespace FeatureFlagDemo { - // - // Define feature flags in an enum - public enum MyFeatureFlags + static class MyFeatureFlags { - Home, - Beta, - CustomViewData, - ContentEnhancement, - EnhancedPipeline + public const string Home = "Home"; + public const string Beta = "Beta"; + public const string CustomViewData = "CustomViewData"; + public const string ContentEnhancement = "ContentEnhancement"; + public const string EnhancedPipeline = "EnhancedPipeline"; } } diff --git a/examples/FeatureFlagDemo/Program.cs b/examples/FeatureFlagDemo/Program.cs index c9aff31a..ef411bc9 100644 --- a/examples/FeatureFlagDemo/Program.cs +++ b/examples/FeatureFlagDemo/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -12,6 +13,10 @@ public class Program { public static void Main(string[] args) { + // + // Opt-in to use new schema with features received from Azure App Configuration + Environment.SetEnvironmentVariable("AZURE_APP_CONFIGURATION_FEATURE_MANAGEMENT_SCHEMA_VERSION", "2"); + CreateWebHostBuilder(args).Build().Run(); } diff --git a/examples/FeatureFlagDemo/Startup.cs b/examples/FeatureFlagDemo/Startup.cs index 65c0777c..8ddba6a1 100644 --- a/examples/FeatureFlagDemo/Startup.cs +++ b/examples/FeatureFlagDemo/Startup.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Assigners; using Microsoft.FeatureManagement.FeatureFilters; namespace FeatureFlagDemo @@ -59,8 +60,11 @@ public void ConfigureServices(IServiceCollection services) .AddFeatureFilter() .AddFeatureFilter() .AddFeatureFilter() + .AddFeatureVariantAssigner() .UseDisabledFeaturesHandler(new FeatureNotEnabledDisabledHandler()); + services.AddAzureAppConfiguration(); + services.AddMvc(o => { o.Filters.AddForFeature(nameof(MyFeatureFlags.EnhancedPipeline)); diff --git a/examples/FeatureFlagDemo/SuperUserFilter.cs b/examples/FeatureFlagDemo/SuperUserFilter.cs index 25dc8e5f..48ab33ec 100644 --- a/examples/FeatureFlagDemo/SuperUserFilter.cs +++ b/examples/FeatureFlagDemo/SuperUserFilter.cs @@ -2,13 +2,14 @@ // Licensed under the MIT license. // using Microsoft.FeatureManagement; +using System.Threading; using System.Threading.Tasks; namespace FeatureFlagDemo.FeatureManagement.FeatureFilters { public class SuperUserFilter : IFeatureFilter { - public Task EvaluateAsync(FeatureFilterEvaluationContext context) + public Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken) { return Task.FromResult(false); } diff --git a/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml b/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml index 8ca281ef..f8a5d76b 100644 --- a/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml +++ b/examples/FeatureFlagDemo/Views/Shared/_Layout.cshtml @@ -1,4 +1,10 @@ - +@using Microsoft.FeatureManagement +@inject IDynamicFeatureManager dynamicFeatureManager; +@{ + DiscountBannerOptions opts = await dynamicFeatureManager.GetVariantAsync("DiscountBanner", Context.RequestAborted); +} + + @@ -15,6 +21,14 @@ asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute" /> + - +
+ New Sale, 50% Off ! +
@RenderBody()
diff --git a/examples/FeatureFlagDemo/appsettings.json b/examples/FeatureFlagDemo/appsettings.json index d0a37270..7ae75e61 100644 --- a/examples/FeatureFlagDemo/appsettings.json +++ b/examples/FeatureFlagDemo/appsettings.json @@ -5,67 +5,114 @@ } }, "AllowedHosts": "*", - // Define feature flags in config file "FeatureManagement": { - - "Home": true, - "Beta": { - "EnabledFor": [ - { - "Name": "Targeting", - "Parameters": { // This json object maps to a strongly typed configuration class - "Audience": { - "Users": [ - "Jeff", - "Alicia" - ], - "Groups": [ - { - "Name": "Ring0", - "RolloutPercentage": 80 - }, - { - "Name": "Ring1", - "RolloutPercentage": 50 - } - ], - "DefaultRolloutPercentage": 20 + "FeatureFlags": { + "Home": true, + "Beta": { + "EnabledFor": [ + { + "Name": "Targeting", + "Parameters": { // This json object maps to a strongly typed configuration class + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 80 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20 + } } } - } - ] - }, - "CustomViewData": { - "EnabledFor": [ - { - "Name": "Browser", - "Parameters": { - "AllowedBrowsers": [ "Chrome", "Edge" ] + ] + }, + "CustomViewData": { + "EnabledFor": [ + { + "Name": "Browser", + "Parameters": { + "AllowedBrowsers": [ "Chrome", "Edge" ] + } } - } - ] - }, - "ContentEnhancement": { - "EnabledFor": [ - { - "Name": "TimeWindow", - "Parameters": { - "Start": "Wed, 01 May 2019 13:59:59 GMT", - "End": "Mon, 01 July 2019 00:00:00 GMT" + ] + }, + "ContentEnhancement": { + "EnabledFor": [ + { + "Name": "TimeWindow", + "Parameters": { + "Start": "Wed, 01 May 2019 13:59:59 GMT", + "End": "Mon, 01 July 2019 00:00:00 GMT" + } + } + ] + }, + "EnhancedPipeline": { + "EnabledFor": [ + { + "Name": "Microsoft.Percentage", + "Parameters": { + "Value": 50 + } } - } - ] + ] + } }, - "EnhancedPipeline": { - "EnabledFor": [ - { - "Name": "Microsoft.Percentage", - "Parameters": { - "Value": 50 + "DynamicFeatures": { + "DiscountBanner": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "Big", + "ConfigurationReference": "DiscountBanner:Big" + }, + { + "Name": "Small", + "ConfigurationReference": "DiscountBanner:Small", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 80 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20 + } + } } - } - ] + ] + } + } + }, + "DiscountBanner": { + "Small": { + "Size": 24, + "Color": "#c9f568", + "Background": "#f35220" + }, + "Big": { + "Size": 48, + "Color": "#007cb3", + "Background": "#ffbb02" } } } diff --git a/examples/TargetingConsoleApp/CartOptions.cs b/examples/TargetingConsoleApp/CartOptions.cs new file mode 100644 index 00000000..eb202dbd --- /dev/null +++ b/examples/TargetingConsoleApp/CartOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Consoto.Banking.HelpDesk +{ + class CartOptions + { + public int Size { get; set; } + + public string Color { get; set; } + } +} diff --git a/examples/TargetingConsoleApp/Program.cs b/examples/TargetingConsoleApp/Program.cs index 953f609e..ca4efd46 100644 --- a/examples/TargetingConsoleApp/Program.cs +++ b/examples/TargetingConsoleApp/Program.cs @@ -5,10 +5,12 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Assigners; using Microsoft.FeatureManagement.FeatureFilters; using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace Consoto.Banking.HelpDesk @@ -29,7 +31,8 @@ public static async Task Main(string[] args) services.AddSingleton(configuration) .AddFeatureManagement() - .AddFeatureFilter(); + .AddFeatureFilter() + .AddFeatureVariantAssigner(); IUserRepository userRepository = new InMemoryUserRepository(); @@ -38,6 +41,7 @@ public static async Task Main(string[] args) using (ServiceProvider serviceProvider = services.BuildServiceProvider()) { IFeatureManager featureManager = serviceProvider.GetRequiredService(); + IDynamicFeatureManager dynamicFeatureManager = serviceProvider.GetRequiredService(); // // We'll simulate a task to run on behalf of each known user @@ -48,7 +52,8 @@ public static async Task Main(string[] args) // Mimic work items in a task-driven console application foreach (string userId in userIds) { - const string FeatureName = "Beta"; + const string FeatureFlagName = "Beta"; + const string DynamicFeatureName = "ShoppingCart"; // // Get user @@ -62,11 +67,27 @@ public static async Task Main(string[] args) Groups = user.Groups }; - bool enabled = await featureManager.IsEnabledAsync(FeatureName, targetingContext); + // + // Evaluate feature flag using targeting + bool enabled = await featureManager + .IsEnabledAsync( + FeatureFlagName, + targetingContext, + CancellationToken.None); + + // + // Retrieve feature variant using targeting + CartOptions cartOptions = await dynamicFeatureManager + .GetVariantAsync( + DynamicFeatureName, + targetingContext, + CancellationToken.None); // // Output results - Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the user '{userId}'."); + Console.WriteLine($"The {FeatureFlagName} feature is {(enabled ? "enabled" : "disabled")} for the user '{userId}'."); + + Console.WriteLine($"User {user.Id} has a {cartOptions.Color} cart with a size of {cartOptions.Size} pixels."); } } } diff --git a/examples/TargetingConsoleApp/TargetingConsoleApp.csproj b/examples/TargetingConsoleApp/TargetingConsoleApp.csproj index c638b298..1230fedf 100644 --- a/examples/TargetingConsoleApp/TargetingConsoleApp.csproj +++ b/examples/TargetingConsoleApp/TargetingConsoleApp.csproj @@ -2,13 +2,13 @@ Exe - net5.0 + net6.0 Consoto.Banking.AccountService - - + + diff --git a/examples/TargetingConsoleApp/appsettings.json b/examples/TargetingConsoleApp/appsettings.json index a5e827d6..a17902c7 100644 --- a/examples/TargetingConsoleApp/appsettings.json +++ b/examples/TargetingConsoleApp/appsettings.json @@ -24,6 +24,47 @@ } } ] + }, + "ShoppingCart": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "Big", + "ConfigurationReference": "ShoppingCart:Big", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Alec", + "Jeff", + "Alicia" + ] + } + } + }, + { + "Name": "Small", + "ConfigurationReference": "ShoppingCart:Small", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Susan", + "JohnDoe" + ] + } + } + } + ] + } + }, + "ShoppingCart": { + "Big": { + "Size": 400, + "Color": "green" + }, + "Small": { + "Size": 150, + "Color": "gray" } } } \ No newline at end of file diff --git a/pack.cmd b/pack.cmd deleted file mode 100644 index 209cd5e0..00000000 --- a/pack.cmd +++ /dev/null @@ -1,8 +0,0 @@ -call %~dp0build\ChoosePowerShell.cmd - -IF %ERRORLEVEL% NEQ 0 ( - - exit /B 1 -) - -%PowerShell% "%~dp0pack.ps1" %* \ No newline at end of file diff --git a/pack.ps1 b/pack.ps1 index a05e485c..12434211 100644 --- a/pack.ps1 +++ b/pack.ps1 @@ -29,12 +29,14 @@ if ((Test-Path -Path $LogDirectory) -ne $true) { New-Item -ItemType Directory -Path $LogDirectory | Write-Verbose } +$dotnet = & "$PSScriptRoot/build/resolve-dotnet.ps1" + foreach ($project in $targetProjects) { $projectPath = "$PSScriptRoot\src\$project\$project.csproj" $outputPath = "$PSScriptRoot\src\$project\$PublishRelativePath" - dotnet pack -c $BuildConfig -o "$outputPath" "$projectPath" --no-build | Tee-Object -FilePath "$LogDirectory\build.log" + & $dotnet pack -c $BuildConfig -o "$outputPath" "$projectPath" --no-build | Tee-Object -FilePath "$LogDirectory\build.log" } exit $LASTEXITCODE diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs index c2573b8c..df04fc03 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs @@ -12,90 +12,90 @@ namespace Microsoft.FeatureManagement.Mvc { /// - /// An attribute that can be placed on MVC controllers, controller actions, or Razor pages to require all or any of a set of features to be enabled. + /// An attribute that can be placed on MVC controllers, controller actions, or Razor pages to require all or any of a set of feature flags to be enabled. /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] public class FeatureGateAttribute : ActionFilterAttribute, IAsyncPageFilter { /// - /// Creates an attribute that will gate actions or pages unless all the provided feature(s) are enabled. + /// Creates an attribute that will gate actions or pages unless all the provided feature flag(s) are enabled. /// - /// The names of the features that the attribute will represent. - public FeatureGateAttribute(params string[] features) - : this(RequirementType.All, features) + /// The names of the feature flags that the attribute will represent. + public FeatureGateAttribute(params string[] featureFlags) + : this(RequirementType.All, featureFlags) { } /// - /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to require all or any of the provided feature(s) to pass. + /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to require all or any of the provided feature flag(s) to pass. /// - /// Specifies whether all or any of the provided features should be enabled in order to pass. - /// The names of the features that the attribute will represent. - public FeatureGateAttribute(RequirementType requirementType, params string[] features) + /// Specifies whether all or any of the provided feature flags should be enabled in order to pass. + /// The names of the feature flags that the attribute will represent. + public FeatureGateAttribute(RequirementType requirementType, params string[] featureFlags) { - if (features == null || features.Length == 0) + if (featureFlags == null || featureFlags.Length == 0) { - throw new ArgumentNullException(nameof(features)); + throw new ArgumentNullException(nameof(featureFlags)); } - Features = features; + FeatureFlags = featureFlags; RequirementType = requirementType; } /// - /// Creates an attribute that will gate actions or pages unless all the provided feature(s) are enabled. + /// Creates an attribute that will gate actions or pages unless all the provided feature flag(s) are enabled. /// - /// A set of enums representing the features that the attribute will represent. + /// A set of enums representing the feature flags that the attribute will represent. public FeatureGateAttribute(params object[] features) : this(RequirementType.All, features) { } /// - /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to require all or any of the provided feature(s) to pass. + /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to require all or any of the provided feature flag(s) to pass. /// - /// Specifies whether all or any of the provided features should be enabled in order to pass. - /// A set of enums representing the features that the attribute will represent. - public FeatureGateAttribute(RequirementType requirementType, params object[] features) + /// Specifies whether all or any of the provided feature flags should be enabled in order to pass. + /// A set of enums representing the feature flags that the attribute will represent. + public FeatureGateAttribute(RequirementType requirementType, params object[] featureFlags) { - if (features == null || features.Length == 0) + if (featureFlags == null || featureFlags.Length == 0) { - throw new ArgumentNullException(nameof(features)); + throw new ArgumentNullException(nameof(featureFlags)); } var fs = new List(); - foreach (object feature in features) + foreach (object feature in featureFlags) { var type = feature.GetType(); if (!type.IsEnum) { // invalid - throw new ArgumentException("The provided features must be enums.", nameof(features)); + throw new ArgumentException("The provided feature flags must be enums.", nameof(featureFlags)); } fs.Add(Enum.GetName(feature.GetType(), feature)); } - Features = fs; + FeatureFlags = fs; RequirementType = requirementType; } /// - /// The name of the features that the feature attribute will activate for. + /// The name of the feature flags that the feature gate attribute will activate for. /// - public IEnumerable Features { get; } + public IEnumerable FeatureFlags { get; } /// - /// Controls whether any or all features in should be enabled to pass. + /// Controls whether any or all feature flags in should be enabled to pass. /// public RequirementType RequirementType { get; } /// - /// Performs controller action pre-procesing to ensure that at least one of the specified features are enabled. + /// Performs controller action pre-procesing to ensure that at least one of the specified feature flags are enabled. /// /// The context of the MVC action. /// The action delegate. @@ -105,10 +105,10 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context IFeatureManagerSnapshot fm = context.HttpContext.RequestServices.GetRequiredService(); // - // Enabled state is determined by either 'any' or 'all' features being enabled. + // Enabled state is determined by either 'any' or 'all' feature flags being enabled. bool enabled = RequirementType == RequirementType.All ? - await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) : - await Features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); + await FeatureFlags.All(async feature => await fm.IsEnabledAsync(feature, context.HttpContext.RequestAborted).ConfigureAwait(false)).ConfigureAwait(false) : + await FeatureFlags.Any(async feature => await fm.IsEnabledAsync(feature, context.HttpContext.RequestAborted).ConfigureAwait(false)).ConfigureAwait(false); if (enabled) { @@ -118,7 +118,7 @@ await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAw { IDisabledFeaturesHandler disabledFeaturesHandler = context.HttpContext.RequestServices.GetService() ?? new NotFoundDisabledFeaturesHandler(); - await disabledFeaturesHandler.HandleDisabledFeatures(Features, context).ConfigureAwait(false); + await disabledFeaturesHandler.HandleDisabledFeatures(FeatureFlags, context).ConfigureAwait(false); } } @@ -135,8 +135,8 @@ public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext contex // // Enabled state is determined by either 'any' or 'all' features being enabled. bool enabled = RequirementType == RequirementType.All ? - await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) : - await Features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); + await FeatureFlags.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) : + await FeatureFlags.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); if (enabled) { diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs index 594620ae..cbac0b57 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGatedAsyncActionFilter.cs @@ -9,28 +9,28 @@ namespace Microsoft.FeatureManagement { /// - /// A place holder MVC filter that is used to dynamically activate a filter based on whether a feature is enabled. + /// A place holder MVC filter that is used to dynamically activate a filter based on whether a feature flag is enabled. /// /// The filter that will be used instead of this placeholder. class FeatureGatedAsyncActionFilter : IAsyncActionFilter where T : IAsyncActionFilter { - public FeatureGatedAsyncActionFilter(string featureName) + public FeatureGatedAsyncActionFilter(string featureFlagName) { - if (string.IsNullOrEmpty(featureName)) + if (string.IsNullOrEmpty(featureFlagName)) { - throw new ArgumentNullException(nameof(featureName)); + throw new ArgumentNullException(nameof(featureFlagName)); } - FeatureName = featureName; + FeatureFlagName = featureFlagName; } - public string FeatureName { get; } + public string FeatureFlagName { get; } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { IFeatureManager featureManager = context.HttpContext.RequestServices.GetRequiredService(); - if (await featureManager.IsEnabledAsync(FeatureName).ConfigureAwait(false)) + if (await featureManager.IsEnabledAsync(FeatureFlagName, context.HttpContext.RequestAborted).ConfigureAwait(false)) { IServiceProvider serviceProvider = context.HttpContext.RequestServices.GetRequiredService(); diff --git a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj index 8e66c36d..fca84cc4 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj +++ b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj @@ -11,7 +11,7 @@ - netstandard2.0;netcoreapp3.1;net5.0 + netstandard2.0;netcoreapp3.1;net6.0 true false ..\..\build\Microsoft.FeatureManagement.snk diff --git a/src/Microsoft.FeatureManagement.AspNetCore/TagHelpers/FeatureTagHelper.cs b/src/Microsoft.FeatureManagement.AspNetCore/TagHelpers/FeatureTagHelper.cs index 4a0da1e0..651c0068 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/TagHelpers/FeatureTagHelper.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/TagHelpers/FeatureTagHelper.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Razor.TagHelpers; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement.Mvc.TagHelpers @@ -55,8 +56,8 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu IEnumerable names = Name.Split(',').Select(n => n.Trim()); enabled = Requirement == RequirementType.All ? - await names.All(async n => await _featureManager.IsEnabledAsync(n).ConfigureAwait(false)) : - await names.Any(async n => await _featureManager.IsEnabledAsync(n).ConfigureAwait(false)); + await names.All(async n => await _featureManager.IsEnabledAsync(n, CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false) : + await names.Any(async n => await _featureManager.IsEnabledAsync(n, CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); } if (Negate) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/UseForFeatureExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/UseForFeatureExtensions.cs index 4774ee9b..c8562b7a 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/UseForFeatureExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/UseForFeatureExtensions.cs @@ -57,7 +57,7 @@ public static IApplicationBuilder UseForFeature(this IApplicationBuilder app, st { IFeatureManager fm = context.RequestServices.GetRequiredService(); - if (await fm.IsEnabledAsync(featureName).ConfigureAwait(false)) + if (await fm.IsEnabledAsync(featureName, context.RequestAborted).ConfigureAwait(false)) { await branch(context).ConfigureAwait(false); } diff --git a/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs b/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs new file mode 100644 index 00000000..f7d8f52b --- /dev/null +++ b/src/Microsoft.FeatureManagement/AssignerAliasAttribute.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; + +namespace Microsoft.FeatureManagement +{ + /// + /// Allows the name of an to be customized to relate to the name specified in configuration. + /// + public class AssignerAliasAttribute : Attribute + { + /// + /// Creates an assigner alias using the provided alias. + /// + /// The alias of the feature variant assigner. + public AssignerAliasAttribute(string alias) + { + if (string.IsNullOrEmpty(alias)) + { + throw new ArgumentNullException(nameof(alias)); + } + + Alias = alias; + } + + /// + /// The name that will be used to match feature feature variant assigners specified in the configuration. + /// + public string Alias { get; } + } +} diff --git a/src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs new file mode 100644 index 00000000..4c50702e --- /dev/null +++ b/src/Microsoft.FeatureManagement/ConfigurationDynamicFeatureDefinitionProvider.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// A feature definition provider that pulls dynamic feature definitions from the .NET Core system. + /// + sealed class ConfigurationDynamicFeatureDefinitionProvider : IDynamicFeatureDefinitionProvider, IDisposable + { + public const string DynamicFeatureDefinitionsSectionName = "DynamicFeatures"; + private const string FeatureManagementSectionName = "FeatureManagement"; + private const string FeatureVariantsSectionName = "Variants"; + private readonly IConfiguration _configuration; + private readonly ConcurrentDictionary _dynamicFeatureDefinitions; + private IDisposable _changeSubscription; + private int _stale = 0; + + public ConfigurationDynamicFeatureDefinitionProvider(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _dynamicFeatureDefinitions = new ConcurrentDictionary(); + + _changeSubscription = ChangeToken.OnChange( + () => _configuration.GetReloadToken(), + () => _stale = 1); + } + + public void Dispose() + { + _changeSubscription?.Dispose(); + + _changeSubscription = null; + } + + public Task GetDynamicFeatureDefinitionAsync(string featureName, CancellationToken cancellationToken = default) + { + if (featureName == null) + { + throw new ArgumentNullException(nameof(featureName)); + } + + EnsureFresh(); + + // + // Query by feature name + DynamicFeatureDefinition definition = _dynamicFeatureDefinitions.GetOrAdd(featureName, (name) => ReadDynamicFeatureDefinition(name)); + + return Task.FromResult(definition); + } + + // + // The async key word is necessary for creating IAsyncEnumerable. + // The need to disable this warning occurs when implementaing async stream synchronously. +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async IAsyncEnumerable GetAllDynamicFeatureDefinitionsAsync([EnumeratorCancellation] CancellationToken cancellationToken) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + EnsureFresh(); + + // + // Iterate over all features registered in the system at initial invocation time + foreach (IConfigurationSection featureSection in GetDynamicFeatureDefinitionSections()) + { + // + // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned + yield return _dynamicFeatureDefinitions.GetOrAdd(featureSection.Key, (_) => ReadDynamicFeatureDefinition(featureSection)); + } + } + + private DynamicFeatureDefinition ReadDynamicFeatureDefinition(string featureName) + { + IConfigurationSection configuration = GetDynamicFeatureDefinitionSections() + .FirstOrDefault(section => section.Key.Equals(featureName, StringComparison.OrdinalIgnoreCase)); + + if (configuration == null) + { + return null; + } + + return ReadDynamicFeatureDefinition(configuration); + } + + private DynamicFeatureDefinition ReadDynamicFeatureDefinition(IConfigurationSection configurationSection) + { + Debug.Assert(configurationSection != null); + + var variants = new List(); + + foreach (IConfigurationSection section in configurationSection.GetSection(FeatureVariantsSectionName).GetChildren()) + { + if (int.TryParse(section.Key, out int _) && !string.IsNullOrEmpty(section[nameof(FeatureVariant.Name)])) + { + variants.Add(new FeatureVariant + { + Default = section.GetValue(nameof(FeatureVariant.Default)), + Name = section.GetValue(nameof(FeatureVariant.Name)), + ConfigurationReference = section.GetValue(nameof(FeatureVariant.ConfigurationReference)), + AssignmentParameters = section.GetSection(nameof(FeatureVariant.AssignmentParameters)) + }); + } + } + + return new DynamicFeatureDefinition() + { + Name = configurationSection.Key, + Variants = variants, + Assigner = configurationSection.GetValue(nameof(DynamicFeatureDefinition.Assigner)) + }; + } + + private IEnumerable GetDynamicFeatureDefinitionSections() + { + // + // Look for feature definitions under the "FeatureManagement" section + IConfiguration featureManagementSection = _configuration.GetChildren().Any(s => s.Key.Equals(FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase)) ? + _configuration.GetSection(FeatureManagementSectionName) : + _configuration; + + return featureManagementSection + .GetSection(DynamicFeatureDefinitionsSectionName) + .GetChildren(); + } + + private void EnsureFresh() + { + if (Interlocked.Exchange(ref _stale, 0) != 0) + { + _dynamicFeatureDefinitions.Clear(); + } + } + } +} diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureFlagDefinitionProvider.cs similarity index 57% rename from src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs rename to src/Microsoft.FeatureManagement/ConfigurationFeatureFlagDefinitionProvider.cs index 8763b850..e3aea38d 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureFlagDefinitionProvider.cs @@ -6,27 +6,31 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement { /// - /// A feature definition provider that pulls feature definitions from the .NET Core system. + /// A feature definition provider that pulls feature flag definitions from the .NET Core system. /// - sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionProvider, IDisposable + sealed class ConfigurationFeatureFlagDefinitionProvider : IFeatureFlagDefinitionProvider, IDisposable { + private const string FeatureManagementSectionName = "FeatureManagement"; + private const string FeatureFlagDefinitionsSectionName = "FeatureFlags"; private const string FeatureFiltersSectionName = "EnabledFor"; private readonly IConfiguration _configuration; - private readonly ConcurrentDictionary _definitions; + private readonly ConcurrentDictionary _featureFlagDefinitions; private IDisposable _changeSubscription; private int _stale = 0; - public ConfigurationFeatureDefinitionProvider(IConfiguration configuration) + public ConfigurationFeatureFlagDefinitionProvider(IConfiguration configuration) { _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _definitions = new ConcurrentDictionary(); + _featureFlagDefinitions = new ConcurrentDictionary(); _changeSubscription = ChangeToken.OnChange( () => _configuration.GetReloadToken(), @@ -40,21 +44,18 @@ public void Dispose() _changeSubscription = null; } - public Task GetFeatureDefinitionAsync(string featureName) + public Task GetFeatureFlagDefinitionAsync(string featureName, CancellationToken cancellationToken) { if (featureName == null) { throw new ArgumentNullException(nameof(featureName)); } - if (Interlocked.Exchange(ref _stale, 0) != 0) - { - _definitions.Clear(); - } + EnsureFresh(); // // Query by feature name - FeatureDefinition definition = _definitions.GetOrAdd(featureName, (name) => ReadFeatureDefinition(name)); + FeatureFlagDefinition definition = _featureFlagDefinitions.GetOrAdd(featureName, (name) => ReadFeatureFlagDefinition(name)); return Task.FromResult(definition); } @@ -63,27 +64,24 @@ public Task GetFeatureDefinitionAsync(string featureName) // The async key word is necessary for creating IAsyncEnumerable. // The need to disable this warning occurs when implementaing async stream synchronously. #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() + public async IAsyncEnumerable GetAllFeatureFlagDefinitionsAsync([EnumeratorCancellation] CancellationToken cancellationToken) #pragma warning restore CS1998 { - if (Interlocked.Exchange(ref _stale, 0) != 0) - { - _definitions.Clear(); - } + EnsureFresh(); // // Iterate over all features registered in the system at initial invocation time - foreach (IConfigurationSection featureSection in GetFeatureDefinitionSections()) + foreach (IConfigurationSection featureSection in GetFeatureFlagDefinitionSections()) { // // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned - yield return _definitions.GetOrAdd(featureSection.Key, (_) => ReadFeatureDefinition(featureSection)); + yield return _featureFlagDefinitions.GetOrAdd(featureSection.Key, (_) => ReadFeatureFlagDefinition(featureSection)); } } - private FeatureDefinition ReadFeatureDefinition(string featureName) + private FeatureFlagDefinition ReadFeatureFlagDefinition(string featureName) { - IConfigurationSection configuration = GetFeatureDefinitionSections() + IConfigurationSection configuration = GetFeatureFlagDefinitionSections() .FirstOrDefault(section => section.Key.Equals(featureName, StringComparison.OrdinalIgnoreCase)); if (configuration == null) @@ -91,10 +89,10 @@ private FeatureDefinition ReadFeatureDefinition(string featureName) return null; } - return ReadFeatureDefinition(configuration); + return ReadFeatureFlagDefinition(configuration); } - private FeatureDefinition ReadFeatureDefinition(IConfigurationSection configurationSection) + private FeatureFlagDefinition ReadFeatureFlagDefinition(IConfigurationSection configurationSection) { /* @@ -125,6 +123,8 @@ We support */ + Debug.Assert(configurationSection != null); + var enabledFor = new List(); string val = configurationSection.Value; // configuration[$"{featureName}"]; @@ -156,9 +156,9 @@ We support // // Arrays in json such as "myKey": [ "some", "values" ] // Are accessed through the configuration system by using the array index as the property name, e.g. "myKey": { "0": "some", "1": "values" } - if (int.TryParse(section.Key, out int i) && !string.IsNullOrEmpty(section[nameof(FeatureFilterConfiguration.Name)])) + if (int.TryParse(section.Key, out int _) && !string.IsNullOrEmpty(section[nameof(FeatureFilterConfiguration.Name)])) { - enabledFor.Add(new FeatureFilterConfiguration() + enabledFor.Add(new FeatureFilterConfiguration { Name = section[nameof(FeatureFilterConfiguration.Name)], Parameters = section.GetSection(nameof(FeatureFilterConfiguration.Parameters)) @@ -167,26 +167,49 @@ We support } } - return new FeatureDefinition() + return new FeatureFlagDefinition() { Name = configurationSection.Key, - EnabledFor = enabledFor + EnabledFor = enabledFor, }; } - private IEnumerable GetFeatureDefinitionSections() + private IEnumerable GetFeatureFlagDefinitionSections() { - const string FeatureManagementSectionName = "FeatureManagement"; + // + // Look for feature definitions under the "FeatureManagement" section + IConfiguration featureManagementSection = _configuration.GetChildren().Any(s => s.Key.Equals(FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase)) ? + _configuration.GetSection(FeatureManagementSectionName) : + _configuration; + + IEnumerable featureManagementChildren = featureManagementSection.GetChildren(); + + IConfigurationSection featureFlagsSection = featureManagementChildren.FirstOrDefault(s => s.Key == FeatureFlagDefinitionsSectionName); - if (_configuration.GetChildren().Any(s => s.Key.Equals(FeatureManagementSectionName, StringComparison.OrdinalIgnoreCase))) + // + // Check for mixed schema to avoid confusing scenario where feature flags defined in separate sources with different schemas don't mix. + if (featureFlagsSection != null && + featureManagementChildren.Any(section => + !section.Key.Equals(FeatureFlagDefinitionsSectionName) && + !section.Key.Equals(ConfigurationDynamicFeatureDefinitionProvider.DynamicFeatureDefinitionsSectionName))) { - // - // Look for feature definitions under the "FeatureManagement" section - return _configuration.GetSection(FeatureManagementSectionName).GetChildren(); + throw new FeatureManagementException( + FeatureManagementError.InvalidConfiguration, + "Detected feature flags defined using different feature management schemas."); } - else + + // + // Support backward compatability where feature flag definitions were directly under the feature management section + return featureFlagsSection == null ? + featureManagementChildren : + featureFlagsSection.GetChildren(); + } + + private void EnsureFresh() + { + if (Interlocked.Exchange(ref _stale, 0) != 0) { - return _configuration.GetChildren(); + _featureFlagDefinitions.Clear(); } } } diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs new file mode 100644 index 00000000..c5868056 --- /dev/null +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureVariantOptionsResolver.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// A feature variant options resolver that resolves options by reading configuration from the .NET Core system. + /// + sealed class ConfigurationFeatureVariantOptionsResolver : IFeatureVariantOptionsResolver + { + private readonly IConfiguration _configuration; + + public ConfigurationFeatureVariantOptionsResolver(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public ValueTask GetOptionsAsync(DynamicFeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken) + { + if (variant == null) + { + throw new ArgumentNullException(nameof(variant)); + } + + IConfiguration configuration = _configuration.GetSection($"{variant.ConfigurationReference}"); + + return new ValueTask(configuration.Get()); + } + } +} diff --git a/src/Microsoft.FeatureManagement/ContextualFeatureFilterEvaluator.cs b/src/Microsoft.FeatureManagement/ContextualFeatureFilterEvaluator.cs index baf9220a..9f75edd9 100644 --- a/src/Microsoft.FeatureManagement/ContextualFeatureFilterEvaluator.cs +++ b/src/Microsoft.FeatureManagement/ContextualFeatureFilterEvaluator.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement @@ -15,7 +16,7 @@ namespace Microsoft.FeatureManagement class ContextualFeatureFilterEvaluator : IContextualFeatureFilter { private IFeatureFilterMetadata _filter; - private Func> _evaluateFunc; + private Func> _evaluateFunc; public ContextualFeatureFilterEvaluator(IFeatureFilterMetadata filter, Type appContextType) { @@ -43,18 +44,33 @@ public ContextualFeatureFilterEvaluator(IFeatureFilterMetadata filter, Type appC _filter = filter; } - public Task EvaluateAsync(FeatureFilterEvaluationContext evaluationContext, object context) + public Task EvaluateAsync(FeatureFilterEvaluationContext evaluationContext, object context, CancellationToken cancellationToken) { + if (evaluationContext == null) + { + throw new ArgumentNullException(nameof(evaluationContext)); + } + if (_evaluateFunc == null) { return Task.FromResult(false); } - return _evaluateFunc(_filter, evaluationContext, context); + return _evaluateFunc(_filter, evaluationContext, context, cancellationToken); } public static bool IsContextualFilter(IFeatureFilterMetadata filter, Type appContextType) { + if (filter == null) + { + throw new ArgumentNullException(nameof(filter)); + } + + if (appContextType == null) + { + throw new ArgumentNullException(nameof(appContextType)); + } + return GetContextualFilterInterface(filter, appContextType) != null; } @@ -72,7 +88,7 @@ private static Type GetContextualFilterInterface(IFeatureFilterMetadata filter, return targetInterface; } - private static Func> TypeAgnosticEvaluate(Type filterType, MethodInfo method) + private static Func> TypeAgnosticEvaluate(Type filterType, MethodInfo method) { // // Get the generic version of the evaluation helper method @@ -82,23 +98,30 @@ private static Func> // // Create a type specific version of the evaluation helper method MethodInfo constructedHelper = genericHelper.MakeGenericMethod - (filterType, method.GetParameters()[0].ParameterType, method.GetParameters()[1].ParameterType, method.ReturnType); + (filterType, + method.GetParameters()[0].ParameterType, + method.GetParameters()[1].ParameterType, + method.GetParameters()[2].ParameterType, + method.ReturnType); // // Invoke the method to get the func object typeAgnosticDelegate = constructedHelper.Invoke(null, new object[] { method }); - return (Func>)typeAgnosticDelegate; + return (Func>)typeAgnosticDelegate; } - private static Func> GenericTypeAgnosticEvaluate(MethodInfo method) + private static Func> GenericTypeAgnosticEvaluate(MethodInfo method) { - Func> func = (Func>)Delegate.CreateDelegate - (typeof(Func>), method); + Func> func = + (Func>) + Delegate.CreateDelegate(typeof(Func>), method); - Func> genericDelegate = (object target, FeatureFilterEvaluationContext param1, object param2) => func((TTarget)target, param1, (TParam2)param2); + Func> genericDelegate = + (object target, FeatureFilterEvaluationContext param1, object param2, CancellationToken param3) => + func((TTarget)target, param1, (TParam2)param2, param3); return genericDelegate; } } -} +} \ No newline at end of file diff --git a/src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs b/src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs new file mode 100644 index 00000000..edfbd8de --- /dev/null +++ b/src/Microsoft.FeatureManagement/ContextualFeatureVariantAssignerEvaluator.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Provides a performance efficient method of evaluating without knowing what the generic type parameter is. + /// + sealed class ContextualFeatureVariantAssignerEvaluator : IContextualFeatureVariantAssigner + { + private IFeatureVariantAssignerMetadata _assigner; + private Func> _evaluateFunc; + + public ContextualFeatureVariantAssignerEvaluator(IFeatureVariantAssignerMetadata assigner, Type appContextType) + { + if (assigner == null) + { + throw new ArgumentNullException(nameof(assigner)); + } + + if (appContextType == null) + { + throw new ArgumentNullException(nameof(appContextType)); + } + + Type targetInterface = GetContextualAssignerInterface(assigner, appContextType); + + // + // Extract IContextualFeatureVariantAssigner.AssignVariantAsync method. + if (targetInterface != null) + { + MethodInfo evaluateMethod = targetInterface.GetMethod(nameof(IContextualFeatureVariantAssigner.AssignVariantAsync), BindingFlags.Public | BindingFlags.Instance); + + _evaluateFunc = TypeAgnosticEvaluate(assigner.GetType(), evaluateMethod); + } + + _assigner = assigner; + } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext assignmentContext, object context, CancellationToken cancellationToken) + { + if (assignmentContext == null) + { + throw new ArgumentNullException(nameof(assignmentContext)); + } + + if (_evaluateFunc == null) + { + return new ValueTask((FeatureVariant)null); + } + + return _evaluateFunc(_assigner, assignmentContext, context, cancellationToken); + } + + public static bool IsContextualVariantAssigner(IFeatureVariantAssignerMetadata assigner, Type appContextType) + { + if (assigner == null) + { + throw new ArgumentNullException(nameof(assigner)); + } + + if (appContextType == null) + { + throw new ArgumentNullException(nameof(appContextType)); + } + + return GetContextualAssignerInterface(assigner, appContextType) != null; + } + + private static Type GetContextualAssignerInterface(IFeatureVariantAssignerMetadata assigner, Type appContextType) + { + IEnumerable contextualAssignerInterfaces = assigner.GetType().GetInterfaces().Where(i => i.IsGenericType && i.GetGenericTypeDefinition().IsAssignableFrom(typeof(IContextualFeatureVariantAssigner<>))); + + Type targetInterface = null; + + if (contextualAssignerInterfaces != null) + { + targetInterface = contextualAssignerInterfaces.FirstOrDefault(i => i.GetGenericArguments()[0].IsAssignableFrom(appContextType)); + } + + return targetInterface; + } + + private static Func> TypeAgnosticEvaluate(Type assignerType, MethodInfo method) + { + // + // Get the generic version of the evaluation helper method + MethodInfo genericHelper = typeof(ContextualFeatureVariantAssignerEvaluator).GetMethod(nameof(GenericTypeAgnosticEvaluate), + BindingFlags.Static | BindingFlags.NonPublic); + + // + // Create a type specific version of the evaluation helper method + MethodInfo constructedHelper = genericHelper.MakeGenericMethod + (assignerType, + method.GetParameters()[0].ParameterType, + method.GetParameters()[1].ParameterType, + method.GetParameters()[2].ParameterType, + method.ReturnType); + + // + // Invoke the method to get the func + object typeAgnosticDelegate = constructedHelper.Invoke(null, new object[] { method }); + + return (Func>)typeAgnosticDelegate; + } + + private static Func> GenericTypeAgnosticEvaluate(MethodInfo method) + { + Func> func = + (Func>) + Delegate.CreateDelegate(typeof(Func>), method); + + Func> genericDelegate = + (object target, FeatureVariantAssignmentContext param1, object param2, CancellationToken param3) => + func((TTarget)target, param1, (TParam2)param2, param3); + + return genericDelegate; + } + } +} diff --git a/src/Microsoft.FeatureManagement/DynamicFeatureDefinition.cs b/src/Microsoft.FeatureManagement/DynamicFeatureDefinition.cs new file mode 100644 index 00000000..8dfb95cc --- /dev/null +++ b/src/Microsoft.FeatureManagement/DynamicFeatureDefinition.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.FeatureManagement +{ + /// + /// The definition of a dynamic feature. + /// + public class DynamicFeatureDefinition + { + /// + /// The name of the dynamic feature. + /// + public string Name { get; set; } + + /// + /// The assigner used to pick the variant that should be used when a variant is requested + /// + public string Assigner { get; set; } + + /// + /// The feature variants listed for this dynamic feature. + /// + public IEnumerable Variants { get; set; } = Enumerable.Empty(); + } +} diff --git a/src/Microsoft.FeatureManagement/DynamicFeatureManager.cs b/src/Microsoft.FeatureManagement/DynamicFeatureManager.cs new file mode 100644 index 00000000..17ff110a --- /dev/null +++ b/src/Microsoft.FeatureManagement/DynamicFeatureManager.cs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Used to retrieve variants for dynamic features. + /// + class DynamicFeatureManager : IDynamicFeatureManager + { + private readonly IDynamicFeatureDefinitionProvider _featureDefinitionProvider; + private readonly IEnumerable _variantAssigners; + private readonly IFeatureVariantOptionsResolver _variantOptionsResolver; + private readonly ConcurrentDictionary _assignerMetadataCache; + private readonly ConcurrentDictionary _contextualFeatureVariantAssignerCache; + + public DynamicFeatureManager( + IDynamicFeatureDefinitionProvider featureDefinitionProvider, + IEnumerable variantAssigner, + IFeatureVariantOptionsResolver variantOptionsResolver) + { + _variantAssigners = variantAssigner ?? throw new ArgumentNullException(nameof(variantAssigner)); + _variantOptionsResolver = variantOptionsResolver ?? throw new ArgumentNullException(nameof(variantOptionsResolver)); + _featureDefinitionProvider = featureDefinitionProvider ?? throw new ArgumentNullException(nameof(featureDefinitionProvider)); + _assignerMetadataCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + _contextualFeatureVariantAssignerCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + + public async IAsyncEnumerable GetDynamicFeatureNamesAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (DynamicFeatureDefinition featureDefintion in _featureDefinitionProvider.GetAllDynamicFeatureDefinitionsAsync(cancellationToken).ConfigureAwait(false)) + { + yield return featureDefintion.Name; + } + } + + public ValueTask GetVariantAsync(string feature, TContext appContext, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(feature)) + { + throw new ArgumentNullException(nameof(feature)); + } + + return GetVariantAsync(feature, appContext, true, cancellationToken); + } + + public ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(feature)) + { + throw new ArgumentNullException(nameof(feature)); + } + + return GetVariantAsync(feature, null, false, cancellationToken); + } + + private async ValueTask GetVariantAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) + { + if (feature == null) + { + throw new ArgumentNullException(nameof(feature)); + } + + FeatureVariant variant = null; + + DynamicFeatureDefinition featureDefinition = await _featureDefinitionProvider + .GetDynamicFeatureDefinitionAsync(feature, cancellationToken) + .ConfigureAwait(false); + + if (featureDefinition == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeature, + $"The feature declaration for the dynamic feature '{feature}' was not found."); + } + + if (string.IsNullOrEmpty(featureDefinition.Assigner)) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureVariantAssigner, + $"Missing feature variant assigner name for the feature {feature}"); + } + + if (featureDefinition.Variants == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureVariant, + $"No variants are registered for the feature {feature}"); + } + + FeatureVariant defaultVariant = null; + + foreach (FeatureVariant v in featureDefinition.Variants) + { + if (v.Default) + { + if (defaultVariant != null) + { + throw new FeatureManagementException( + FeatureManagementError.AmbiguousDefaultFeatureVariant, + $"Multiple default variants are registered for the feature '{feature}'."); + } + + defaultVariant = v; + } + + if (v.ConfigurationReference == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingConfigurationReference, + $"The variant '{variant.Name}' for the feature '{feature}' does not have a configuration reference."); + } + } + + if (defaultVariant == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingDefaultFeatureVariant, + $"A default variant cannot be found for the feature '{feature}'."); + } + + IFeatureVariantAssignerMetadata assigner = GetFeatureVariantAssignerMetadata(featureDefinition.Assigner); + + if (assigner == null) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureVariantAssigner, + $"The feature variant assigner '{featureDefinition.Assigner}' specified for feature '{feature}' was not found."); + } + + var context = new FeatureVariantAssignmentContext() + { + FeatureDefinition = featureDefinition + }; + + // + // IFeatureVariantAssigner + if (assigner is IFeatureVariantAssigner featureVariantAssigner) + { + variant = await featureVariantAssigner.AssignVariantAsync(context, cancellationToken).ConfigureAwait(false); + } + // + // IContextualFeatureVariantAssigner + else if (useAppContext && + TryGetContextualFeatureVariantAssigner(featureDefinition.Assigner, typeof(TContext), out ContextualFeatureVariantAssignerEvaluator contextualAssigner)) + { + variant = await contextualAssigner.AssignVariantAsync(context, appContext, cancellationToken).ConfigureAwait(false); + } + // + // The assigner doesn't implement a feature variant assigner interface capable of performing the evaluation + else + { + throw new FeatureManagementException( + FeatureManagementError.InvalidFeatureVariantAssigner, + useAppContext ? + $"The feature variant assigner '{featureDefinition.Assigner}' specified for the feature '{feature}' is not capable of evaluating the requested feature with the provided context." : + $"The feature variant assigner '{featureDefinition.Assigner}' specified for the feature '{feature}' is not capable of evaluating the requested feature."); + } + + if (variant == null) + { + variant = defaultVariant; + } + + return await _variantOptionsResolver.GetOptionsAsync(featureDefinition, variant, cancellationToken).ConfigureAwait(false); + } + + private IFeatureVariantAssignerMetadata GetFeatureVariantAssignerMetadata(string assignerName) + { + const string assignerSuffix = "assigner"; + + IFeatureVariantAssignerMetadata assigner = _assignerMetadataCache.GetOrAdd( + assignerName, + (_) => { + + IEnumerable matchingAssigners = _variantAssigners.Where(a => + { + Type assignerType = a.GetType(); + + string name = ((AssignerAliasAttribute)Attribute.GetCustomAttribute(assignerType, typeof(AssignerAliasAttribute)))?.Alias; + + if (name == null) + { + name = assignerType.Name; + } + + return NameHelper.IsMatchingReference( + reference: assignerName, + metadataName: name, + suffix: assignerSuffix); + }); + + if (matchingAssigners.Count() > 1) + { + throw new FeatureManagementException(FeatureManagementError.AmbiguousFeatureVariantAssigner, $"Multiple feature variant assigners match the configured assigner named '{assignerName}'."); + } + + return matchingAssigners.FirstOrDefault(); + } + ); + + return assigner; + } + + private bool TryGetContextualFeatureVariantAssigner(string assignerName, Type appContextType, out ContextualFeatureVariantAssignerEvaluator assigner) + { + if (appContextType == null) + { + throw new ArgumentNullException(nameof(appContextType)); + } + + assigner = _contextualFeatureVariantAssignerCache.GetOrAdd( + $"{assignerName}{Environment.NewLine}{appContextType.FullName}", + (_) => { + + IFeatureVariantAssignerMetadata metadata = GetFeatureVariantAssignerMetadata(assignerName); + + return ContextualFeatureVariantAssignerEvaluator.IsContextualVariantAssigner(metadata, appContextType) ? + new ContextualFeatureVariantAssignerEvaluator(metadata, appContextType) : + null; + } + ); + + return assigner != null; + } + } +} diff --git a/src/Microsoft.FeatureManagement/DynamicFeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/DynamicFeatureManagerSnapshot.cs new file mode 100644 index 00000000..517c496f --- /dev/null +++ b/src/Microsoft.FeatureManagement/DynamicFeatureManagerSnapshot.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Provides a snapshot of feature state to ensure consistency across a given request. + /// + class DynamicFeatureManagerSnapshot : IDynamicFeatureManagerSnapshot + { + private readonly IDynamicFeatureManager _dynamicFeatureManager; + private readonly IDictionary _variantCache = new Dictionary(); + private IEnumerable _dynamicFeatureNames; + + public DynamicFeatureManagerSnapshot(IDynamicFeatureManager dynamicFeatureManager) + { + _dynamicFeatureManager = dynamicFeatureManager ?? throw new ArgumentNullException(nameof(dynamicFeatureManager)); + } + + public async IAsyncEnumerable GetDynamicFeatureNamesAsync([EnumeratorCancellation]CancellationToken cancellationToken = default) + { + if (_dynamicFeatureNames == null) + { + var dynamicFeatureNames = new List(); + + await foreach (string featureName in _dynamicFeatureManager.GetDynamicFeatureNamesAsync(cancellationToken).ConfigureAwait(false)) + { + dynamicFeatureNames.Add(featureName); + } + + _dynamicFeatureNames = dynamicFeatureNames; + } + + foreach (string featureName in _dynamicFeatureNames) + { + yield return featureName; + } + } + + public async ValueTask GetVariantAsync(string feature, CancellationToken cancellationToken) + { + string cacheKey = GetVariantCacheKey(feature); + + // + // First, check local cache + if (_variantCache.ContainsKey(feature)) + { + return (T)_variantCache[cacheKey]; + } + + T variant = await _dynamicFeatureManager.GetVariantAsync(feature, cancellationToken).ConfigureAwait(false); + + _variantCache[cacheKey] = variant; + + return variant; + } + + public async ValueTask GetVariantAsync(string feature, TContext context, CancellationToken cancellationToken) + { + string cacheKey = GetVariantCacheKey(feature); + + // + // First, check local cache + if (_variantCache.ContainsKey(feature)) + { + return (T)_variantCache[cacheKey]; + } + + T variant = await _dynamicFeatureManager.GetVariantAsync(feature, context, cancellationToken).ConfigureAwait(false); + + _variantCache[cacheKey] = variant; + + return variant; + } + + private string GetVariantCacheKey(string feature) + { + return $"{typeof(T).FullName}\n{feature}"; + } + } +} diff --git a/src/Microsoft.FeatureManagement/EmptySessionManager.cs b/src/Microsoft.FeatureManagement/EmptySessionManager.cs index fd4b3d3f..7658a0e4 100644 --- a/src/Microsoft.FeatureManagement/EmptySessionManager.cs +++ b/src/Microsoft.FeatureManagement/EmptySessionManager.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement @@ -10,12 +11,12 @@ namespace Microsoft.FeatureManagement /// class EmptySessionManager : ISessionManager { - public Task SetAsync(string featureName, bool enabled) + public Task SetAsync(string featureName, bool enabled, CancellationToken cancellationToken) { return Task.CompletedTask; } - public Task GetAsync(string featureName) + public Task GetAsync(string featureName, CancellationToken cancellationToken) { return Task.FromResult((bool?)null); } diff --git a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs index 5d461c7f..d35c5736 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs @@ -6,17 +6,17 @@ namespace Microsoft.FeatureManagement { /// - /// A context used by to gain insight into what feature is being evaluated and the parameters needed to check whether the feature should be enabled. + /// A context used by to gain insight into what feature flag is being evaluated and the parameters needed to check whether the feature flag should be enabled. /// public class FeatureFilterEvaluationContext { /// - /// The name of the feature being evaluated. + /// The name of the feature flag being evaluated. /// - public string FeatureName { get; set; } + public string FeatureFlagName { get; set; } /// - /// The settings provided for the feature filter to use when evaluating whether the feature should be enabled. + /// The settings provided for the feature filter to use when evaluating whether the feature flag should be enabled. /// public IConfiguration Parameters { get; set; } } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs index 2ece90c2..84f00930 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.FeatureManagement.Utils; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement.FeatureFilters @@ -27,11 +28,12 @@ public PercentageFilter(ILoggerFactory loggerFactory) } /// - /// Performs a percentage based evaluation to determine whether a feature is enabled. + /// Performs a percentage based evaluation to determine whether a feature flag is enabled. /// /// The feature evaluation context. - /// True if the feature is enabled, false otherwise. - public Task EvaluateAsync(FeatureFilterEvaluationContext context) + /// The cancellation token to cancel the operation. + /// True if the feature flag is enabled, false otherwise. + public Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken) { PercentageFilterSettings settings = context.Parameters.Get() ?? new PercentageFilterSettings(); @@ -39,7 +41,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) if (settings.Value < 0) { - _logger.LogWarning($"The '{Alias}' feature filter does not have a valid '{nameof(settings.Value)}' value for feature '{context.FeatureName}'"); + _logger.LogWarning($"The '{Alias}' feature filter does not have a valid '{nameof(settings.Value)}' value for the feature flag '{context.FeatureFlagName}'"); result = false; } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilterSettings.cs b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilterSettings.cs index 81124e28..9a68bc8a 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilterSettings.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilterSettings.cs @@ -9,7 +9,7 @@ namespace Microsoft.FeatureManagement.FeatureFilters public class PercentageFilterSettings { /// - /// A value between 0 and 100 specifying the chance that a feature configured to use the should be enabled. + /// A value between 0 and 100 specifying the chance that a feature flag configured to use the should be enabled. /// public int Value { get; set; } = -1; } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs index 97c21b07..0a9282d1 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs @@ -4,12 +4,13 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement.FeatureFilters { /// - /// A feature filter that can be used to activate a feature based on a time window. + /// A feature filter that can be used to activate a feature flag based on a time window. /// [FilterAlias(Alias)] public class TimeWindowFilter : IFeatureFilter @@ -27,11 +28,12 @@ public TimeWindowFilter(ILoggerFactory loggerFactory) } /// - /// Evaluates whether a feature is enabled based on a configurable time window. + /// Evaluates whether a feature flag is enabled based on a configurable time window. /// /// The feature evaluation context. - /// True if the feature is enabled, false otherwise. - public Task EvaluateAsync(FeatureFilterEvaluationContext context) + /// The cancellation token to cancel the operation. + /// True if the feature flag is enabled, false otherwise. + public Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken) { TimeWindowFilterSettings settings = context.Parameters.Get() ?? new TimeWindowFilterSettings(); @@ -39,7 +41,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) if (!settings.Start.HasValue && !settings.End.HasValue) { - _logger.LogWarning($"The '{Alias}' feature filter is not valid for feature '{context.FeatureName}'. It must have have specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both."); + _logger.LogWarning($"The '{Alias}' feature filter is not valid for the feature flag '{context.FeatureFlagName}'. It must have have specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both."); return Task.FromResult(false); } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs index 41f87cf3..48709dbf 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs @@ -11,13 +11,13 @@ namespace Microsoft.FeatureManagement.FeatureFilters public class TimeWindowFilterSettings { /// - /// An optional start time used to determine when a feature configured to use the feature filter should be enabled. + /// An optional start time used to determine when a feature flag configured to use the feature filter should be enabled. /// If no start time is specified the time window is considered to have already started. /// public DateTimeOffset? Start { get; set; } // E.g. "Wed, 01 May 2019 22:59:30 GMT" /// - /// An optional end time used to determine when a feature configured to use the feature filter should be enabled. + /// An optional end time used to determine when a feature flag configured to use the feature filter should be enabled. /// If no end time is specified the time window is considered to never end. /// public DateTimeOffset? End { get; set; } // E.g. "Wed, 01 May 2019 23:00:00 GMT" diff --git a/src/Microsoft.FeatureManagement/FeatureDefinition.cs b/src/Microsoft.FeatureManagement/FeatureFlagDefinition.cs similarity index 58% rename from src/Microsoft.FeatureManagement/FeatureDefinition.cs rename to src/Microsoft.FeatureManagement/FeatureFlagDefinition.cs index 4931fd5b..4cce2194 100644 --- a/src/Microsoft.FeatureManagement/FeatureDefinition.cs +++ b/src/Microsoft.FeatureManagement/FeatureFlagDefinition.cs @@ -2,22 +2,23 @@ // Licensed under the MIT license. // using System.Collections.Generic; +using System.Linq; namespace Microsoft.FeatureManagement { /// - /// The definition of a feature. + /// The definition of a feature flag. /// - public class FeatureDefinition + public class FeatureFlagDefinition { /// - /// The name of the feature. + /// The name of the feature flag. /// public string Name { get; set; } /// - /// The feature filters that the feature can be enabled for. + /// The feature filters that the feature flag can be enabled for. /// - public IEnumerable EnabledFor { get; set; } = new List(); + public IEnumerable EnabledFor { get; set; } = Enumerable.Empty(); } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs index a433b2bc..a0cbb8f3 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs @@ -43,6 +43,29 @@ public IFeatureManagementBuilder AddFeatureFilter() where T : IFeatureFilterM return this; } + public IFeatureManagementBuilder AddFeatureVariantAssigner() where T : IFeatureVariantAssignerMetadata + { + Type serviceType = typeof(IFeatureVariantAssignerMetadata); + + Type implementationType = typeof(T); + + IEnumerable featureVariantAssignerImplementations = implementationType.GetInterfaces() + .Where(i => i == typeof(IFeatureVariantAssigner) || + (i.IsGenericType && i.GetGenericTypeDefinition().IsAssignableFrom(typeof(IContextualFeatureVariantAssigner<>)))); + + if (featureVariantAssignerImplementations.Count() > 1) + { + throw new ArgumentException($"A single feature variant assigner cannot implement more than one feature variant assigner interface.", nameof(T)); + } + + if (!Services.Any(descriptor => descriptor.ServiceType == serviceType && descriptor.ImplementationType == implementationType)) + { + Services.AddSingleton(typeof(IFeatureVariantAssignerMetadata), typeof(T)); + } + + return this; + } + public IFeatureManagementBuilder AddSessionManager() where T : ISessionManager { Services.AddSingleton(typeof(ISessionManager), typeof(T)); diff --git a/src/Microsoft.FeatureManagement/FeatureManagementError.cs b/src/Microsoft.FeatureManagement/FeatureManagementError.cs index 1cdb9eed..a8deb232 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementError.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementError.cs @@ -21,6 +21,51 @@ public enum FeatureManagementError /// /// A feature that was requested for evaluation was not found. /// - MissingFeature + MissingFeature, + + /// + /// A feature filter being used in feature evaluation is invalid. + /// + InvalidFeatureFilter, + + /// + /// A feature variant assigner that was listed for variant assignment was not found. + /// + MissingFeatureVariantAssigner, + + /// + /// The feature variant assigner configured for the feature being evaluated is an ambiguous reference to multiple registered feature variant assigners. + /// + AmbiguousFeatureVariantAssigner, + + /// + /// An assigned feature variant does not have a valid configuration reference. + /// + MissingConfigurationReference, + + /// + /// A feature variant assigner being used in feature evaluation is invalid. + /// + InvalidFeatureVariantAssigner, + + /// + /// A dynamic feature does not have any feature variants registered. + /// + MissingFeatureVariant, + + /// + /// A dynamic feature has multiple default feature variants configured. + /// + AmbiguousDefaultFeatureVariant, + + /// + /// A dynamic feature does not have a default feature variant configured. + /// + MissingDefaultFeatureVariant, + + /// + /// A configuration error is present in the feature management system. + /// + InvalidConfiguration } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs b/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs index e425c054..e12f9f05 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementOptions.cs @@ -10,7 +10,7 @@ public class FeatureManagementOptions { /// /// Controls the behavior of feature evaluation when dependent feature filters are missing. - /// If missing feature filters are not ignored an exception will be thrown when attempting to evaluate a feature that depends on a missing feature filter. + /// If missing feature filters are not ignored an exception will be thrown when attempting to evaluate a feature flag that depends on a missing feature filter. /// The default value is false. /// public bool IgnoreMissingFeatureFilters { get; set; } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index 097af3a2..25784045 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -7,6 +7,8 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement @@ -16,7 +18,7 @@ namespace Microsoft.FeatureManagement /// class FeatureManager : IFeatureManager { - private readonly IFeatureDefinitionProvider _featureDefinitionProvider; + private readonly IFeatureFlagDefinitionProvider _featureDefinitionProvider; private readonly IEnumerable _featureFilters; private readonly IEnumerable _sessionManagers; private readonly ILogger _logger; @@ -25,44 +27,54 @@ class FeatureManager : IFeatureManager private readonly FeatureManagementOptions _options; public FeatureManager( - IFeatureDefinitionProvider featureDefinitionProvider, + IFeatureFlagDefinitionProvider featureDefinitionProvider, IEnumerable featureFilters, IEnumerable sessionManagers, ILoggerFactory loggerFactory, IOptions options) { - _featureDefinitionProvider = featureDefinitionProvider; _featureFilters = featureFilters ?? throw new ArgumentNullException(nameof(featureFilters)); + _featureDefinitionProvider = featureDefinitionProvider ?? throw new ArgumentNullException(nameof(featureDefinitionProvider)); _sessionManagers = sessionManagers ?? throw new ArgumentNullException(nameof(sessionManagers)); _logger = loggerFactory.CreateLogger(); - _filterMetadataCache = new ConcurrentDictionary(); - _contextualFeatureFilterCache = new ConcurrentDictionary(); + _filterMetadataCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + _contextualFeatureFilterCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); } - public Task IsEnabledAsync(string feature) + public Task IsEnabledAsync(string feature, CancellationToken cancellationToken) { - return IsEnabledAsync(feature, null, false); + if (string.IsNullOrEmpty(feature)) + { + throw new ArgumentNullException(nameof(feature)); + } + + return IsEnabledAsync(feature, null, false, cancellationToken); } - public Task IsEnabledAsync(string feature, TContext appContext) + public Task IsEnabledAsync(string feature, TContext appContext, CancellationToken cancellationToken) { - return IsEnabledAsync(feature, appContext, true); + if (string.IsNullOrEmpty(feature)) + { + throw new ArgumentNullException(nameof(feature)); + } + + return IsEnabledAsync(feature, appContext, true, cancellationToken); } - public async IAsyncEnumerable GetFeatureNamesAsync() + public async IAsyncEnumerable GetFeatureFlagNamesAsync([EnumeratorCancellation] CancellationToken cancellationToken) { - await foreach (FeatureDefinition featureDefintion in _featureDefinitionProvider.GetAllFeatureDefinitionsAsync().ConfigureAwait(false)) + await foreach (FeatureFlagDefinition featureDefintion in _featureDefinitionProvider.GetAllFeatureFlagDefinitionsAsync(cancellationToken).ConfigureAwait(false)) { yield return featureDefintion.Name; } } - private async Task IsEnabledAsync(string feature, TContext appContext, bool useAppContext) + private async Task IsEnabledAsync(string feature, TContext appContext, bool useAppContext, CancellationToken cancellationToken) { foreach (ISessionManager sessionManager in _sessionManagers) { - bool? readSessionResult = await sessionManager.GetAsync(feature).ConfigureAwait(false); + bool? readSessionResult = await sessionManager.GetAsync(feature, cancellationToken).ConfigureAwait(false); if (readSessionResult.HasValue) { @@ -72,7 +84,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo bool enabled = false; - FeatureDefinition featureDefinition = await _featureDefinitionProvider.GetFeatureDefinitionAsync(feature).ConfigureAwait(false); + FeatureFlagDefinition featureDefinition = await _featureDefinitionProvider.GetFeatureFlagDefinitionAsync(feature, cancellationToken).ConfigureAwait(false); if (featureDefinition != null) { @@ -92,6 +104,13 @@ private async Task IsEnabledAsync(string feature, TContext appCo foreach (FeatureFilterConfiguration featureFilterConfiguration in featureDefinition.EnabledFor) { + if (string.IsNullOrEmpty(featureFilterConfiguration.Name)) + { + throw new FeatureManagementException( + FeatureManagementError.MissingFeatureFilter, + $"Missing feature filter name for the feature {feature}"); + } + IFeatureFilterMetadata filter = GetFeatureFilterMetadata(featureFilterConfiguration.Name); if (filter == null) @@ -112,31 +131,42 @@ private async Task IsEnabledAsync(string feature, TContext appCo var context = new FeatureFilterEvaluationContext() { - FeatureName = feature, - Parameters = featureFilterConfiguration.Parameters + FeatureFlagName = featureDefinition.Name, + Parameters = featureFilterConfiguration.Parameters }; // - // IContextualFeatureFilter - if (useAppContext) + // IFeatureFilter + if (filter is IFeatureFilter featureFilter) { - ContextualFeatureFilterEvaluator contextualFilter = GetContextualFeatureFilter(featureFilterConfiguration.Name, typeof(TContext)); - - if (contextualFilter != null && await contextualFilter.EvaluateAsync(context, appContext).ConfigureAwait(false)) + if (await featureFilter.EvaluateAsync(context, cancellationToken).ConfigureAwait(false)) { enabled = true; break; } } - // - // IFeatureFilter - if (filter is IFeatureFilter featureFilter && await featureFilter.EvaluateAsync(context).ConfigureAwait(false)) + // IContextualFeatureFilter + else if (useAppContext && + TryGetContextualFeatureFilter(featureFilterConfiguration.Name, typeof(TContext), out ContextualFeatureFilterEvaluator contextualFilter)) { - enabled = true; + if (await contextualFilter.EvaluateAsync(context, appContext, cancellationToken).ConfigureAwait(false)) + { + enabled = true; - break; + break; + } + } + // + // The filter doesn't implement a feature filter interface capable of performing the evaluation + else + { + throw new FeatureManagementException( + FeatureManagementError.InvalidFeatureFilter, + useAppContext ? + $"The feature filter '{featureFilterConfiguration.Name}' specified for the feature '{feature}' is not capable of evaluating the requested feature with the provided context." : + $"The feature filter '{featureFilterConfiguration.Name}' specified for the feature '{feature}' is not capable of evaluating the requested feature."); } } } @@ -157,7 +187,7 @@ private async Task IsEnabledAsync(string feature, TContext appCo foreach (ISessionManager sessionManager in _sessionManagers) { - await sessionManager.SetAsync(feature, enabled).ConfigureAwait(false); + await sessionManager.SetAsync(feature, enabled, cancellationToken).ConfigureAwait(false); } return enabled; @@ -173,33 +203,19 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) IEnumerable matchingFilters = _featureFilters.Where(f => { - Type t = f.GetType(); + Type filterType = f.GetType(); - string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(t, typeof(FilterAliasAttribute)))?.Alias; + string name = ((FilterAliasAttribute)Attribute.GetCustomAttribute(filterType, typeof(FilterAliasAttribute)))?.Alias; if (name == null) { - name = t.Name.EndsWith(filterSuffix, StringComparison.OrdinalIgnoreCase) ? t.Name.Substring(0, t.Name.Length - filterSuffix.Length) : t.Name; + name = filterType.Name; } - // - // Feature filters can have namespaces in their alias - // If a feature is configured to use a filter without a namespace such as 'MyFilter', then it can match 'MyOrg.MyProduct.MyFilter' or simply 'MyFilter' - // If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter' - if (filterName.Contains('.')) - { - // - // The configured filter name is namespaced. It must be an exact match. - return string.Equals(name, filterName, StringComparison.OrdinalIgnoreCase); - } - else - { - // - // We take the simple name of a filter, E.g. 'MyFilter' for 'MyOrg.MyProduct.MyFilter' - string simpleName = name.Contains('.') ? name.Split('.').Last() : name; - - return string.Equals(simpleName, filterName, StringComparison.OrdinalIgnoreCase); - } + return NameHelper.IsMatchingReference( + reference: filterName, + metadataName: name, + suffix: filterSuffix); }); if (matchingFilters.Count() > 1) @@ -214,14 +230,14 @@ private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName) return filter; } - private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filterName, Type appContextType) + private bool TryGetContextualFeatureFilter(string filterName, Type appContextType, out ContextualFeatureFilterEvaluator filter) { if (appContextType == null) { throw new ArgumentNullException(nameof(appContextType)); } - ContextualFeatureFilterEvaluator filter = _contextualFeatureFilterCache.GetOrAdd( + filter = _contextualFeatureFilterCache.GetOrAdd( $"{filterName}{Environment.NewLine}{appContextType.FullName}", (_) => { @@ -233,7 +249,7 @@ private ContextualFeatureFilterEvaluator GetContextualFeatureFilter(string filte } ); - return filter; + return filter != null; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs index 1c676cbb..e61dbe49 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagerSnapshot.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement @@ -15,45 +17,45 @@ class FeatureManagerSnapshot : IFeatureManagerSnapshot { private readonly IFeatureManager _featureManager; private readonly ConcurrentDictionary> _flagCache = new ConcurrentDictionary>(); - private IEnumerable _featureNames; + private IEnumerable _featureFlagNames; public FeatureManagerSnapshot(IFeatureManager featureManager) { _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); } - public async IAsyncEnumerable GetFeatureNamesAsync() + public async IAsyncEnumerable GetFeatureFlagNamesAsync([EnumeratorCancellation]CancellationToken cancellationToken) { - if (_featureNames == null) + if (_featureFlagNames == null) { var featureNames = new List(); - await foreach (string featureName in _featureManager.GetFeatureNamesAsync().ConfigureAwait(false)) + await foreach (string featureName in _featureManager.GetFeatureFlagNamesAsync(cancellationToken).ConfigureAwait(false)) { featureNames.Add(featureName); } - _featureNames = featureNames; + _featureFlagNames = featureNames; } - foreach (string featureName in _featureNames) + foreach (string featureName in _featureFlagNames) { yield return featureName; } } - public Task IsEnabledAsync(string feature) + public Task IsEnabledAsync(string feature, CancellationToken cancellationToken) { return _flagCache.GetOrAdd( feature, - (key) => _featureManager.IsEnabledAsync(key)); + (key) => _featureManager.IsEnabledAsync(key, cancellationToken)); } - public Task IsEnabledAsync(string feature, TContext context) + public Task IsEnabledAsync(string feature, TContext context, CancellationToken cancellationToken) { return _flagCache.GetOrAdd( feature, - (key) => _featureManager.IsEnabledAsync(key, context)); + (key) => _featureManager.IsEnabledAsync(key, context, cancellationToken)); } } } diff --git a/src/Microsoft.FeatureManagement/FeatureVariant.cs b/src/Microsoft.FeatureManagement/FeatureVariant.cs new file mode 100644 index 00000000..7701c5f0 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureVariant.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; + +namespace Microsoft.FeatureManagement +{ + /// + /// A variant of a feature. + /// + public class FeatureVariant + { + /// + /// The name of the variant. + /// + public string Name { get; set; } + + /// + /// Determines whether this variant should be chosen by default if no variant is chosen during the assignment process. + /// + public bool Default { get; set; } + + /// + /// The parameters to be used during assignment to test whether the variant should be used. + /// + public IConfiguration AssignmentParameters { get; set; } + + /// + /// A reference pointing to the configuration for this variant of the feature. + /// + public string ConfigurationReference { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs b/src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs new file mode 100644 index 00000000..0620288b --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureVariantAssignmentContext.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement +{ + /// + /// Contextual information needed during the process of feature variant assignment + /// + public class FeatureVariantAssignmentContext + { + /// + /// The definition of the dynamic feature in need of an assigned variant + /// + public DynamicFeatureDefinition FeatureDefinition { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/IContextualFeatureFilter.cs b/src/Microsoft.FeatureManagement/IContextualFeatureFilter.cs index 64586334..1f1eff68 100644 --- a/src/Microsoft.FeatureManagement/IContextualFeatureFilter.cs +++ b/src/Microsoft.FeatureManagement/IContextualFeatureFilter.cs @@ -1,13 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement { /// - /// A filter that can be used to determine whether some criteria is met to enable a feature. A feature filter is free to use any criteria available, such as process state or request content. - /// Feature filters can be registered for a given feature and if any feature filter evaluates to true, that feature will be considered enabled. + /// A filter that can be used to determine whether some criteria is met to enable a feature flag. A feature filter is free to use any criteria available, such as process state or request content. + /// Feature filters can be registered for a given feature and if any feature filter evaluates to true, that feature flag will be considered enabled. /// A contextual feature filter can take advantage of contextual data passed in from callers of the feature management system. /// A contextual feature filter will only be executed if a context that is assignable from TContext is available. /// @@ -16,9 +17,10 @@ public interface IContextualFeatureFilter : IFeatureFilterMetadata /// /// Evaluates the feature filter to see if the filter's criteria for being enabled has been satisfied. /// - /// A feature filter evaluation context that contains information that may be needed to evaluate the filter. This context includes configuration, if any, for this filter for the feature being evaluated. - /// A context defined by the application that is passed in to the feature management system to provide contextual information for evaluating a feature's state. + /// A feature filter evaluation context that contains information that may be needed to evaluate the filter. This context includes configuration, if any, for this filter for the feature flag being evaluated. + /// A context defined by the application that is passed in to the feature management system to provide contextual information for evaluating a feature flag's state. + /// The cancellation token to cancel the operation. /// True if the filter's criteria has been met, false otherwise. - Task EvaluateAsync(FeatureFilterEvaluationContext featureFilterContext, TContext appContext); + Task EvaluateAsync(FeatureFilterEvaluationContext featureFilterContext, TContext appContext, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs new file mode 100644 index 00000000..9dab47de --- /dev/null +++ b/src/Microsoft.FeatureManagement/IContextualFeatureVariantAssigner.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Provides a method to assign a variant of a dynamic feature to be used based off of custom conditions. + /// + /// A custom type that the assigner requires to perform assignment + public interface IContextualFeatureVariantAssigner : IFeatureVariantAssignerMetadata + { + /// + /// Assign a variant of a dynamic feature to be used based off of customized criteria. + /// + /// A variant assignment context that contains information needed to assign a variant for a dynamic feature. + /// A context defined by the application that is passed in to the feature management system to provide contextual information for assigning a variant of a dynamic feature. + /// The cancellation token to cancel the operation. + /// The variant that should be assigned for a given dynamic feature. + ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, TContext appContext, CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.FeatureManagement/IDynamicFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/IDynamicFeatureDefinitionProvider.cs new file mode 100644 index 00000000..9f805ce1 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IDynamicFeatureDefinitionProvider.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// A provider of dynamic feature definitions. + /// + public interface IDynamicFeatureDefinitionProvider + { + /// + /// Retrieves the definition for a given dynamic feature. + /// + /// The name of the dynamic feature to retrieve the definition for. + /// The cancellation token to cancel the operation. + /// The dynamic feature's definition. + Task GetDynamicFeatureDefinitionAsync(string dynamicFeatureName, CancellationToken cancellationToken = default); + + /// + /// Retrieves definitions for all dynamic features. + /// + /// The cancellation token to cancel the operation. + /// An enumerator which provides asynchronous iteration over dynamic feature definitions. + IAsyncEnumerable GetAllDynamicFeatureDefinitionsAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.FeatureManagement/IDynamicFeatureManager.cs b/src/Microsoft.FeatureManagement/IDynamicFeatureManager.cs new file mode 100644 index 00000000..e1785c08 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IDynamicFeatureManager.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Used to access the variants of a dynamic feature. + /// + public interface IDynamicFeatureManager + { + /// + /// Retrieves a list of dynamic feature names registered in the feature manager. + /// + /// The cancellation token to cancel the operation. + /// An enumerator which provides asynchronous iteration over the dynamic feature names registered in the feature manager. + IAsyncEnumerable GetDynamicFeatureNamesAsync(CancellationToken cancellationToken = default); + + /// + /// Retrieves a typed representation of the feature variant that should be used for a given dynamic feature. + /// + /// The type that the feature variant's configuration should be bound to. + /// The name of the dynamic feature. + /// The cancellation token to cancel the operation. + /// A typed representation of the feature variant that should be used for a given dynamic feature. + ValueTask GetVariantAsync(string dynamicFeature, CancellationToken cancellationToken = default); + + /// + /// Retrieves a typed representation of the feature variant that should be used for a given dynamic feature. + /// + /// The type that the feature variant's configuration should be bound to. + /// The type of the context being provided to the dynamic feature manager for use during the process of choosing which variant to use. + /// The name of the dynamic feature. + /// A context providing information that can be used to evaluate which variant should be used for the dynamic feature. + /// The cancellation token to cancel the operation. + /// A typed representation of the feature variant's configuration that should be used for a given feature. + ValueTask GetVariantAsync(string dynamicFeature, TContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.FeatureManagement/IDynamicFeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/IDynamicFeatureManagerSnapshot.cs new file mode 100644 index 00000000..1a5b34c1 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IDynamicFeatureManagerSnapshot.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement +{ + /// + /// Provides a snapshot of feature state to ensure consistency across a given request. + /// + public interface IDynamicFeatureManagerSnapshot : IDynamicFeatureManager + { + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/IFeatureDefinitionProvider.cs deleted file mode 100644 index bc4895b9..00000000 --- a/src/Microsoft.FeatureManagement/IFeatureDefinitionProvider.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Microsoft.FeatureManagement -{ - /// - /// A provider of feature definitions. - /// - public interface IFeatureDefinitionProvider - { - /// - /// Retrieves the definition for a given feature. - /// - /// The name of the feature to retrieve the definition for. - /// The feature's definition. - Task GetFeatureDefinitionAsync(string featureName); - - /// - /// Retrieves definitions for all features. - /// - /// An enumerator which provides asynchronous iteration over feature definitions. - IAsyncEnumerable GetAllFeatureDefinitionsAsync(); - } -} diff --git a/src/Microsoft.FeatureManagement/IFeatureFilter.cs b/src/Microsoft.FeatureManagement/IFeatureFilter.cs index e6d914df..a5b9daab 100644 --- a/src/Microsoft.FeatureManagement/IFeatureFilter.cs +++ b/src/Microsoft.FeatureManagement/IFeatureFilter.cs @@ -1,20 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement { /// - /// A filter that can be used to determine whether some criteria is met to enable a feature. A feature filter is free to use any criteria available, such as process state or request content. Feature filters can be registered for a given feature and if any feature filter evaluates to true, that feature will be considered enabled. + /// A filter that can be used to determine whether some criteria is met to enable a feature flag. A feature filter is free to use any criteria available, such as process state or request content. Feature filters can be registered for a given feature flag and if any feature filter evaluates to true, that feature flag will be considered enabled. /// public interface IFeatureFilter : IFeatureFilterMetadata { /// /// Evaluates the feature filter to see if the filter's criteria for being enabled has been satisfied. /// - /// A feature filter evaluation context that contains information that may be needed to evaluate the filter. This context includes configuration, if any, for this filter for the feature being evaluated. + /// A feature filter evaluation context that contains information that may be needed to evaluate the filter. This context includes configuration, if any, for this filter for the feature flag being evaluated. + /// The cancellation token to cancel the operation. /// True if the filter's criteria has been met, false otherwise. - Task EvaluateAsync(FeatureFilterEvaluationContext context); + Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.FeatureManagement/IFeatureFilterMetadata.cs b/src/Microsoft.FeatureManagement/IFeatureFilterMetadata.cs index bfa75a02..d7df2439 100644 --- a/src/Microsoft.FeatureManagement/IFeatureFilterMetadata.cs +++ b/src/Microsoft.FeatureManagement/IFeatureFilterMetadata.cs @@ -4,7 +4,7 @@ namespace Microsoft.FeatureManagement { /// - /// Marker interface for feature filters used to evaluate the state of a feature + /// Marker interface for feature filters used to evaluate the state of a feature flag. /// public interface IFeatureFilterMetadata { diff --git a/src/Microsoft.FeatureManagement/IFeatureFlagDefinitionProvider.cs b/src/Microsoft.FeatureManagement/IFeatureFlagDefinitionProvider.cs new file mode 100644 index 00000000..024e28ad --- /dev/null +++ b/src/Microsoft.FeatureManagement/IFeatureFlagDefinitionProvider.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// A provider of feature flag definitions. + /// + public interface IFeatureFlagDefinitionProvider + { + /// + /// Retrieves the definition for a given feature flag. + /// + /// The name of the feature flag to retrieve the definition for. + /// The cancellation token to cancel the operation. + /// The feature flag's definition. + Task GetFeatureFlagDefinitionAsync(string featureFlagName, CancellationToken cancellationToken = default); + + /// + /// Retrieves definitions for all feature flags. + /// + /// The cancellation token to cancel the operation. + /// An enumerator which provides asynchronous iteration over feature flag definitions. + IAsyncEnumerable GetAllFeatureFlagDefinitionsAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs b/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs index 6365c098..4ac42822 100644 --- a/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs +++ b/src/Microsoft.FeatureManagement/IFeatureManagementBuilder.cs @@ -16,7 +16,7 @@ public interface IFeatureManagementBuilder IServiceCollection Services { get; } /// - /// Adds a given feature filter to the list of feature filters that will be available to enable features during runtime. + /// Adds a given feature filter to the list of feature filters that will be available to enable feature flags during runtime. /// Possible feature filter metadata types include and /// Only one feature filter interface can be implemented by a single type. /// @@ -25,10 +25,19 @@ public interface IFeatureManagementBuilder IFeatureManagementBuilder AddFeatureFilter() where T : IFeatureFilterMetadata; /// - /// Adds an to be used for storing feature state in a session. + /// Adds an to be used for storing feature flag state in a session. /// /// An implementation of /// The feature management builder. IFeatureManagementBuilder AddSessionManager() where T : ISessionManager; + + /// + /// Adds a given feature variant assigner to the list of feature variant assigners that will be available to assign feature variants during runtime. + /// Possible feature variant assigner metadata types include and + /// Only one feature variant assigner interface can be implemented by a single type. + /// + /// An implementation of + /// The feature management builder. + IFeatureManagementBuilder AddFeatureVariantAssigner() where T : IFeatureVariantAssignerMetadata; } } diff --git a/src/Microsoft.FeatureManagement/IFeatureManager.cs b/src/Microsoft.FeatureManagement/IFeatureManager.cs index 1b4ea0cf..ff69fb4e 100644 --- a/src/Microsoft.FeatureManagement/IFeatureManager.cs +++ b/src/Microsoft.FeatureManagement/IFeatureManager.cs @@ -2,34 +2,38 @@ // Licensed under the MIT license. // using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement { /// - /// Used to evaluate whether a feature is enabled or disabled. + /// Used to evaluate whether a feature flag is enabled or disabled. /// public interface IFeatureManager { /// - /// Retrieves a list of feature names registered in the feature manager. + /// Retrieves a list of feature flag names registered in the feature manager. /// - /// An enumerator which provides asynchronous iteration over the feature names registered in the feature manager. - IAsyncEnumerable GetFeatureNamesAsync(); + /// The cancellation token to cancel the operation. + /// An enumerator which provides asynchronous iteration over the feature flag names registered in the feature manager. + IAsyncEnumerable GetFeatureFlagNamesAsync(CancellationToken cancellationToken = default); /// - /// Checks whether a given feature is enabled. + /// Checks whether a given feature flag is enabled. /// - /// The name of the feature to check. - /// True if the feature is enabled, otherwise false. - Task IsEnabledAsync(string feature); + /// The name of the feature flag to check. + /// The cancellation token to cancel the operation. + /// True if the feature flag is enabled, otherwise false. + Task IsEnabledAsync(string featureFlag, CancellationToken cancellationToken = default); /// - /// Checks whether a given feature is enabled. + /// Checks whether a given feature flag is enabled. /// - /// The name of the feature to check. - /// A context providing information that can be used to evaluate whether a feature should be on or off. - /// True if the feature is enabled, otherwise false. - Task IsEnabledAsync(string feature, TContext context); + /// The name of the feature flag to check. + /// A context providing information that can be used to evaluate whether a feature flag should be on or off. + /// The cancellation token to cancel the operation. + /// True if the feature flag is enabled, otherwise false. + Task IsEnabledAsync(string featureFlag, TContext context, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.FeatureManagement/IFeatureManagerSnapshot.cs b/src/Microsoft.FeatureManagement/IFeatureManagerSnapshot.cs index 5ffcdd6b..302e54ab 100644 --- a/src/Microsoft.FeatureManagement/IFeatureManagerSnapshot.cs +++ b/src/Microsoft.FeatureManagement/IFeatureManagerSnapshot.cs @@ -4,7 +4,7 @@ namespace Microsoft.FeatureManagement { /// - /// Provides a snapshot of feature state to ensure consistency across a given request. + /// Provides a snapshot of feature flag state to ensure consistency across a given request. /// public interface IFeatureManagerSnapshot : IFeatureManager { diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs new file mode 100644 index 00000000..cae12197 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IFeatureVariantAssigner.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Provides a method to assign a variant of a dynamic feature to be used based off of custom conditions. + /// + public interface IFeatureVariantAssigner : IFeatureVariantAssignerMetadata + { + /// + /// Assign a variant of a dynamic feature to be used based off of customized criteria. + /// + /// A variant assignment context that contains information needed to assign a variant for a dynamic feature. + /// The cancellation token to cancel the operation. + /// The variant that should be assigned for a given dynamic feature. + ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs b/src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs new file mode 100644 index 00000000..2b09c689 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IFeatureVariantAssignerMetadata.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement +{ + /// + /// Marker interface for feature variant assigners used to assign which variant should be used for a dynamic feature. + /// + public interface IFeatureVariantAssignerMetadata + { + } +} diff --git a/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs b/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs new file mode 100644 index 00000000..760c9358 --- /dev/null +++ b/src/Microsoft.FeatureManagement/IFeatureVariantOptionsResolver.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Performs the resolution and binding necessary in the feature variant resolution process. + /// + public interface IFeatureVariantOptionsResolver + { + /// + /// Retrieves typed options for a given dynamic feature definition and chosen variant. + /// + /// The type of the options to return. + /// The definition of the dynamic feature that the resolution is being performed for. + /// The chosen variant of the dynamic feature. + /// The cancellation token to cancel the operation. + /// Typed options for a given dynamic feature definition and chosen variant. + ValueTask GetOptionsAsync(DynamicFeatureDefinition featureDefinition, FeatureVariant variant, CancellationToken cancellationToken = default); + } +} diff --git a/src/Microsoft.FeatureManagement/ISessionManager.cs b/src/Microsoft.FeatureManagement/ISessionManager.cs index 67189375..f6327932 100644 --- a/src/Microsoft.FeatureManagement/ISessionManager.cs +++ b/src/Microsoft.FeatureManagement/ISessionManager.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement @@ -15,13 +16,15 @@ public interface ISessionManager /// /// The name of the feature. /// The state of the feature. - Task SetAsync(string featureName, bool enabled); + /// The cancellation token to cancel the operation. + Task SetAsync(string featureName, bool enabled, CancellationToken cancellationToken = default); /// /// Queries the session manager for the session's feature state, if any, for the given feature. /// /// The name of the feature. + /// The cancellation token to cancel the operation. /// The state of the feature if it is present in the session, otherwise null. - Task GetAsync(string featureName); + Task GetAsync(string featureName, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj index 3121d633..0c5ddaa3 100644 --- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj +++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj @@ -11,7 +11,7 @@ - netstandard2.0;netcoreapp3.1;net5.0 + netstandard2.0;netcoreapp3.1;net6.0 true false ..\..\build\Microsoft.FeatureManagement.snk diff --git a/src/Microsoft.FeatureManagement/NameHelper.cs b/src/Microsoft.FeatureManagement/NameHelper.cs new file mode 100644 index 00000000..135deabe --- /dev/null +++ b/src/Microsoft.FeatureManagement/NameHelper.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Linq; + +namespace Microsoft.FeatureManagement +{ + static class NameHelper + { + /// + /// Evaluates whether a feature filter or feature variant assigner reference matches a given feature filter/assigner name. + /// + /// A reference to some feature metadata that should be checked for a match with the provided metadata name + /// The name used by the feature filter/feature variant assigner + /// An optional suffix that may be included when referencing the metadata type. E.g. "filter" or "assigner". + /// True if the reference is a match for the metadata name. False otherwise. + public static bool IsMatchingReference(string reference, string metadataName, string suffix) + { + if (string.IsNullOrEmpty(reference)) + { + throw new ArgumentNullException(nameof(reference)); + } + + if (string.IsNullOrEmpty(metadataName)) + { + throw new ArgumentNullException(nameof(metadataName)); + } + + // + // Feature filters/assigner can be referenced with or without their associated suffix ('filter' or 'assigner') + // E.g. A feature can reference a filter named 'CustomFilter' as 'Custom' or 'CustomFilter' + if (!reference.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) && + metadataName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + metadataName = metadataName.Substring(0, metadataName.Length - suffix.Length); + } + + // + // Feature filters/assigners can have namespaces in their alias + // If a feature is configured to use a filter without a namespace such as 'MyFilter', then it can match 'MyOrg.MyProduct.MyFilter' or simply 'MyFilter' + // If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter' + if (reference.Contains('.')) + { + // + // The configured metadata name is namespaced. It must be an exact match. + return string.Equals(metadataName, reference, StringComparison.OrdinalIgnoreCase); + } + else + { + // + // We take the simple name of the metadata, E.g. 'MyFilter' for a feature filter named 'MyOrg.MyProduct.MyFilter' + string simpleName = metadataName.Contains('.') ? metadataName.Split('.').Last() : metadataName; + + return string.Equals(simpleName, reference, StringComparison.OrdinalIgnoreCase); + } + } + } +} diff --git a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs index 507f1394..fdb94aaa 100644 --- a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs +++ b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs @@ -24,13 +24,21 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec // // Add required services - services.TryAddSingleton(); + services.TryAddSingleton(); - services.AddSingleton(); + services.TryAddSingleton(); - services.AddSingleton(); + services.TryAddSingleton(); - services.AddScoped(); + services.TryAddSingleton(); + + services.TryAddSingleton(); + + services.TryAddSingleton(); + + services.TryAddScoped(); + + services.TryAddScoped(); return new FeatureManagementBuilder(services); } @@ -48,7 +56,9 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec throw new ArgumentNullException(nameof(configuration)); } - services.AddSingleton(new ConfigurationFeatureDefinitionProvider(configuration)); + services.AddSingleton(new ConfigurationFeatureFlagDefinitionProvider(configuration)); + + services.AddSingleton(new ConfigurationDynamicFeatureDefinitionProvider(configuration)); return services.AddFeatureManagement(); } diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs new file mode 100644 index 00000000..d2de2e10 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFeatureVariantAssigner.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Microsoft.FeatureManagement.FeatureFilters; +using Microsoft.FeatureManagement.Targeting; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement.Assigners +{ + /// + /// A feature variant assigner that can be used to assign a variant based on targeted audiences. + /// + [AssignerAlias(Alias)] + public class ContextualTargetingFeatureVariantAssigner : IContextualFeatureVariantAssigner + { + private const string Alias = "Microsoft.Targeting"; + private readonly TargetingEvaluationOptions _options; + + /// + /// Creates a targeting contextual feature filter. + /// + /// Options controlling the behavior of the targeting evaluation performed by the filter. + public ContextualTargetingFeatureVariantAssigner(IOptions options) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Assigns one of the variants configured for a dynamic feature based off the provided targeting context. + /// + /// Contextual information available for use during the assignment process. + /// The targeting context used to determine which variant should be assigned. + /// The cancellation token to cancel the operation. + /// + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, ITargetingContext targetingContext, CancellationToken cancellationToken) + { + if (variantAssignmentContext == null) + { + throw new ArgumentNullException(nameof(variantAssignmentContext)); + } + + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + DynamicFeatureDefinition featureDefinition = variantAssignmentContext.FeatureDefinition; + + if (featureDefinition == null) + { + throw new ArgumentException( + $"{nameof(variantAssignmentContext)}.{nameof(variantAssignmentContext.FeatureDefinition)} cannot be null.", + nameof(variantAssignmentContext)); + } + + if (featureDefinition.Variants == null) + { + throw new ArgumentException( + $"{nameof(variantAssignmentContext)}.{nameof(variantAssignmentContext.FeatureDefinition)}.{nameof(featureDefinition.Variants)} cannot be null.", + nameof(variantAssignmentContext)); + } + + var lookup = new Dictionary(); + + // + // Check users + foreach (FeatureVariant v in featureDefinition.Variants) + { + TargetingFilterSettings targetingSettings = v.AssignmentParameters.Get(); + + // + // Put in lookup table to avoid repeatedly creating targeting settings + lookup[v] = targetingSettings; + + if (targetingSettings == null && + v.Default) + { + // + // Valid to omit audience for default variant + continue; + } + + if (!TargetingEvaluator.TryValidateSettings(targetingSettings, out string paramName, out string reason)) + { + throw new ArgumentException(reason, paramName); + } + + // + // Check if the user is being targeted directly + if (targetingSettings.Audience.Users != null && + TargetingEvaluator.IsTargeted( + targetingContext, + targetingSettings.Audience.Users, + _options.IgnoreCase)) + { + return new ValueTask(v); + } + } + + var cumulativeGroups = new Dictionary( + _options.IgnoreCase ? StringComparer.OrdinalIgnoreCase : + StringComparer.Ordinal); + + // + // Check Groups + foreach (FeatureVariant v in featureDefinition.Variants) + { + TargetingFilterSettings targetingSettings = lookup[v]; + + if (targetingSettings == null || + targetingSettings.Audience.Groups == null) + { + continue; + } + + AccumulateGroups(targetingSettings.Audience.Groups, cumulativeGroups); + + if (TargetingEvaluator.IsTargeted( + targetingContext, + targetingSettings.Audience.Groups, + _options.IgnoreCase, + featureDefinition.Name)) + { + return new ValueTask(v); + } + } + + double cumulativePercentage = 0; + + // + // Check default rollout percentage + foreach (FeatureVariant v in featureDefinition.Variants) + { + TargetingFilterSettings targetingSettings = lookup[v]; + + if (targetingSettings == null) + { + continue; + } + + AccumulateDefaultRollout(targetingSettings.Audience, ref cumulativePercentage); + + if (TargetingEvaluator.IsTargeted( + targetingContext, + targetingSettings.Audience.DefaultRolloutPercentage, + _options.IgnoreCase, + featureDefinition.Name)) + { + return new ValueTask(v); + } + } + + return new ValueTask((FeatureVariant)null); + } + + /// + /// Accumulates percentages for groups of an audience. + /// + /// The groups that will have their percentages updated based on currently accumulated percentages + /// The current cumulative rollout percentage for each group + private static void AccumulateGroups(IEnumerable groups, Dictionary cumulativeGroups) + { + foreach (GroupRollout gr in groups) + { + double percentage = gr.RolloutPercentage; + + if (cumulativeGroups.TryGetValue(gr.Name, out double p)) + { + percentage += p; + } + + cumulativeGroups[gr.Name] = percentage; + + gr.RolloutPercentage = percentage; + } + } + + /// + /// Accumulates percentages for the default rollout of an audience. + /// + /// The audience that will have its percentages updated based on currently accumulated percentages + /// The current cumulative default rollout percentage + private static void AccumulateDefaultRollout(Audience audience, ref double cumulativeDefaultPercentage) + { + cumulativeDefaultPercentage = cumulativeDefaultPercentage + audience.DefaultRolloutPercentage; + + audience.DefaultRolloutPercentage = cumulativeDefaultPercentage; + } + } +} diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs index a2d4404e..1ed14d53 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs @@ -2,47 +2,41 @@ // Licensed under the MIT license. // using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.FeatureManagement.Targeting; using System; -using System.Linq; -using System.Security.Cryptography; -using System.Text; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement.FeatureFilters { /// - /// A feature filter that can be used to activate features for targeted audiences. + /// A feature filter that can be used to activate feature flags for targeted audiences. /// [FilterAlias(Alias)] public class ContextualTargetingFilter : IContextualFeatureFilter { private const string Alias = "Microsoft.Targeting"; private readonly TargetingEvaluationOptions _options; - private readonly ILogger _logger; /// /// Creates a targeting contextual feature filter. /// /// Options controlling the behavior of the targeting evaluation performed by the filter. - /// A logger factory for creating loggers. - public ContextualTargetingFilter(IOptions options, ILoggerFactory loggerFactory) + public ContextualTargetingFilter(IOptions options) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); } - private StringComparison ComparisonType => _options.IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - /// - /// Performs a targeting evaluation using the provided to determine if a feature should be enabled. + /// Performs a targeting evaluation using the provided to determine if a feature flag should be enabled. /// /// The feature evaluation context. /// The targeting context to use during targeting evaluation. + /// The cancellation token to cancel the operation. /// Thrown if either or is null. - /// True if the feature is enabled, false otherwise. - public Task EvaluateAsync(FeatureFilterEvaluationContext context, ITargetingContext targetingContext) + /// True if the feature flag is enabled, false otherwise. + public Task EvaluateAsync(FeatureFilterEvaluationContext context, ITargetingContext targetingContext, CancellationToken cancellationToken) { if (context == null) { @@ -56,131 +50,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context, ITargeti TargetingFilterSettings settings = context.Parameters.Get() ?? new TargetingFilterSettings(); - if (!TryValidateSettings(settings, out string paramName, out string message)) - { - throw new ArgumentException(message, paramName); - } - - // - // Check if the user is being targeted directly - if (targetingContext.UserId != null && - settings.Audience.Users != null && - settings.Audience.Users.Any(user => targetingContext.UserId.Equals(user, ComparisonType))) - { - return Task.FromResult(true); - } - - // - // Check if the user is in a group that is being targeted - if (targetingContext.Groups != null && - settings.Audience.Groups != null) - { - foreach (string group in targetingContext.Groups) - { - GroupRollout groupRollout = settings.Audience.Groups.FirstOrDefault(g => g.Name.Equals(group, ComparisonType)); - - if (groupRollout != null) - { - string audienceContextId = $"{targetingContext.UserId}\n{context.FeatureName}\n{group}"; - - if (IsTargeted(audienceContextId, groupRollout.RolloutPercentage)) - { - return Task.FromResult(true); - } - } - } - } - - // - // Check if the user is being targeted by a default rollout percentage - string defaultContextId = $"{targetingContext.UserId}\n{context.FeatureName}"; - - return Task.FromResult(IsTargeted(defaultContextId, settings.Audience.DefaultRolloutPercentage)); - } - - - /// - /// Determines if a given context id should be targeted based off the provided percentage - /// - /// A context identifier that determines what the percentage is applicable for - /// The total percentage of possible context identifiers that should be targeted - /// A boolean representing if the context identifier should be targeted - private bool IsTargeted(string contextId, double percentage) - { - byte[] hash; - - using (HashAlgorithm hashAlgorithm = SHA256.Create()) - { - hash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(contextId)); - } - - // - // Use first 4 bytes for percentage calculation - // Cryptographic hashing algorithms ensure adequate entropy across hash - uint contextMarker = BitConverter.ToUInt32(hash, 0); - - double contextPercentage = (contextMarker / (double)uint.MaxValue) * 100; - - return contextPercentage < percentage; - } - - /// - /// Performs validation of targeting settings. - /// - /// The settings to validate. - /// The name of the invalid setting, if any. - /// The reason that the setting is invalid. - /// True if the provided settings are valid. False if the provided settings are invalid. - private bool TryValidateSettings(TargetingFilterSettings settings, out string paramName, out string reason) - { - const string OutOfRange = "The value is out of the accepted range."; - - const string RequiredParameter = "Value cannot be null."; - - paramName = null; - - reason = null; - - if (settings.Audience == null) - { - paramName = nameof(settings.Audience); - - reason = RequiredParameter; - - return false; - } - - if (settings.Audience.DefaultRolloutPercentage < 0 || settings.Audience.DefaultRolloutPercentage > 100) - { - paramName = $"{settings.Audience}.{settings.Audience.DefaultRolloutPercentage}"; - - reason = OutOfRange; - - return false; - } - - if (settings.Audience.Groups != null) - { - int index = 0; - - foreach (GroupRollout groupRollout in settings.Audience.Groups) - { - index++; - - if (groupRollout.RolloutPercentage < 0 || groupRollout.RolloutPercentage > 100) - { - // - // Audience.Groups[1].RolloutPercentage - paramName = $"{settings.Audience}.{settings.Audience.Groups}[{index}].{groupRollout.RolloutPercentage}"; - - reason = OutOfRange; - - return false; - } - } - } - - return true; + return Task.FromResult(TargetingEvaluator.IsTargeted(targetingContext, settings, _options.IgnoreCase, context.FeatureFlagName)); } } } diff --git a/src/Microsoft.FeatureManagement/Targeting/ITargetingContextAccessor.cs b/src/Microsoft.FeatureManagement/Targeting/ITargetingContextAccessor.cs index 94c1eebd..04e5f709 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ITargetingContextAccessor.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ITargetingContextAccessor.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement.FeatureFilters @@ -13,7 +14,8 @@ public interface ITargetingContextAccessor /// /// Retrieves the current targeting context. /// + /// The cancellation token to cancel the operation. /// The current targeting context. - ValueTask GetContextAsync(); + ValueTask GetContextAsync(CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs new file mode 100644 index 00000000..e5c26ab2 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingEvaluator.cs @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace Microsoft.FeatureManagement.Targeting +{ + static class TargetingEvaluator + { + private static StringComparison GetComparisonType(bool ignoreCase) => + ignoreCase ? + StringComparison.OrdinalIgnoreCase : + StringComparison.Ordinal; + + /// + /// Checks if a provided targeting context should be targeted given targeting settings. + /// + public static bool IsTargeted(ITargetingContext targetingContext, TargetingFilterSettings settings, bool ignoreCase, string hint) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + if (!TryValidateSettings(settings, out string paramName, out string reason)) + { + throw new ArgumentException(reason, paramName); + } + + // + // Check if the user is being targeted directly + if (settings.Audience.Users != null && + IsTargeted( + targetingContext, + settings.Audience.Users, + ignoreCase)) + { + return true; + } + + // + // Check if the user is in a group that is being targeted + if (settings.Audience.Groups != null && + IsTargeted( + targetingContext, + settings.Audience.Groups, + ignoreCase, + hint)) + { + return true; + } + + // + // Check if the user is being targeted by a default rollout percentage + return IsTargeted( + targetingContext, + settings.Audience.DefaultRolloutPercentage, + ignoreCase, + hint); + } + + /// + /// Determines if a targeting context is targeted by presence in a list of users + /// + public static bool IsTargeted( + ITargetingContext targetingContext, + IEnumerable users, + bool ignoreCase) + { + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + if (users == null) + { + throw new ArgumentNullException(nameof(users)); + } + + if (targetingContext.UserId != null && + users.Any(user => targetingContext.UserId.Equals(user, GetComparisonType(ignoreCase)))) + { + return true; + } + + return false; + } + + /// + /// Determine if a targeting context is targeted by presence in a group + /// + public static bool IsTargeted( + ITargetingContext targetingContext, + IEnumerable groups, + bool ignoreCase, + string hint) + { + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + if (groups == null) + { + throw new ArgumentNullException(nameof(groups)); + } + + if (string.IsNullOrEmpty(hint)) + { + throw new ArgumentNullException(nameof(hint)); + } + + string userId = ignoreCase ? + targetingContext.UserId.ToLower() : + targetingContext.UserId; + + if (targetingContext.Groups != null) + { + IEnumerable normalizedGroups = ignoreCase ? + targetingContext.Groups.Select(g => g.ToLower()) : + targetingContext.Groups; + + foreach (string group in normalizedGroups) + { + GroupRollout groupRollout = groups.FirstOrDefault(g => g.Name.Equals(group, GetComparisonType(ignoreCase))); + + if (groupRollout != null) + { + string audienceContextId = $"{userId}\n{hint}\n{group}"; + + if (IsTargeted(audienceContextId, groupRollout.RolloutPercentage)) + { + return true; + } + } + } + } + + return false; + } + + /// + /// Determines if a targeting context is targeted by presence in a default rollout percentage. + /// + public static bool IsTargeted( + ITargetingContext targetingContext, + double defaultRolloutPercentage, + bool ignoreCase, + string hint) + { + if (targetingContext == null) + { + throw new ArgumentNullException(nameof(targetingContext)); + } + + if (string.IsNullOrEmpty(hint)) + { + throw new ArgumentNullException(nameof(hint)); + } + + string userId = ignoreCase ? + targetingContext.UserId.ToLower() : + targetingContext.UserId; + + string defaultContextId = $"{userId}\n{hint}"; + + return IsTargeted(defaultContextId, defaultRolloutPercentage); + } + + /// + /// Performs validation of targeting settings. + /// + /// The settings to validate. + /// The name of the invalid setting, if any. + /// The reason that the setting is invalid. + /// True if the provided settings are valid. False if the provided settings are invalid. + public static bool TryValidateSettings(TargetingFilterSettings targetingSettings, out string paramName, out string reason) + { + const string OutOfRange = "The value is out of the accepted range."; + + const string RequiredParameter = "Value cannot be null."; + + paramName = null; + + reason = null; + + if (targetingSettings == null) + { + paramName = nameof(targetingSettings); + + reason = RequiredParameter; + + return false; + } + + if (targetingSettings.Audience == null) + { + paramName = nameof(targetingSettings.Audience); + + reason = RequiredParameter; + + return false; + } + + if (targetingSettings.Audience.DefaultRolloutPercentage < 0 || targetingSettings.Audience.DefaultRolloutPercentage > 100) + { + paramName = $"{targetingSettings.Audience}.{targetingSettings.Audience.DefaultRolloutPercentage}"; + + reason = OutOfRange; + + return false; + } + + if (targetingSettings.Audience.Groups != null) + { + int index = 0; + + foreach (GroupRollout groupRollout in targetingSettings.Audience.Groups) + { + index++; + + if (groupRollout.RolloutPercentage < 0 || groupRollout.RolloutPercentage > 100) + { + // + // Audience.Groups[1].RolloutPercentage + paramName = $"{targetingSettings.Audience}.{targetingSettings.Audience.Groups}[{index}].{groupRollout.RolloutPercentage}"; + + reason = OutOfRange; + + return false; + } + } + } + + return true; + } + + + /// + /// Determines if a given context id should be targeted based off the provided percentage + /// + /// A context identifier that determines what the percentage is applicable for + /// The total percentage of possible context identifiers that should be targeted + /// A boolean representing if the context identifier should be targeted + private static bool IsTargeted(string contextId, double percentage) + { + byte[] hash; + + using (HashAlgorithm hashAlgorithm = SHA256.Create()) + { + hash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(contextId)); + } + + // + // Use first 4 bytes for percentage calculation + // Cryptographic hashing algorithms ensure adequate entropy across hash + uint contextMarker = BitConverter.ToUInt32(hash, 0); + + double contextPercentage = (contextMarker / (double)uint.MaxValue) * 100; + + return contextPercentage < percentage; + } + } +} diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs new file mode 100644 index 00000000..542df322 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFeatureVariantAssigner.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement.Assigners +{ + /// + /// A feature variant assigner that can be used to assign a variant based on targeted audiences. + /// + [AssignerAlias(Alias)] + public class TargetingFeatureVariantAssigner : IFeatureVariantAssigner + { + private const string Alias = "Microsoft.Targeting"; + private readonly ITargetingContextAccessor _contextAccessor; + private readonly IContextualFeatureVariantAssigner _contextualResolver; + private readonly ILogger _logger; + + /// + /// Creates a feature variant assigner that uses targeting to assign which of a dynamic feature's registered variants should be used. + /// + /// The options controlling how targeting is performed. + /// An accessor for the targeting context required to perform a targeting evaluation. + /// A logger factory for producing logs. + public TargetingFeatureVariantAssigner(IOptions options, + ITargetingContextAccessor contextAccessor, + ILoggerFactory loggerFactory) + { + _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor)); + _contextualResolver = new ContextualTargetingFeatureVariantAssigner(options); + _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); + } + + /// + /// Assigns one of the variants configured for a dynamic feature based off the provided targeting context. + /// + /// Contextual information available for use during the assignment process. + /// The cancellation token to cancel the operation. + /// + public async ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken) + { + if (variantAssignmentContext == null) + { + throw new ArgumentNullException(nameof(variantAssignmentContext)); + } + + // + // Acquire targeting context via accessor + TargetingContext targetingContext = await _contextAccessor.GetContextAsync(cancellationToken).ConfigureAwait(false); + + // + // Ensure targeting can be performed + if (targetingContext == null) + { + _logger.LogWarning("No targeting context available for targeting evaluation."); + + return null; + } + + return await _contextualResolver.AssignVariantAsync(variantAssignmentContext, targetingContext, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs index 283a6260..502fdb4a 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs @@ -4,12 +4,13 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.FeatureManagement.FeatureFilters { /// - /// A feature filter that can be used to activate features for targeted audiences. + /// A feature filter that can be used to activate feature flags for targeted audiences. /// [FilterAlias(Alias)] public class TargetingFilter : IFeatureFilter @@ -28,17 +29,18 @@ public class TargetingFilter : IFeatureFilter public TargetingFilter(IOptions options, ITargetingContextAccessor contextAccessor, ILoggerFactory loggerFactory) { _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor)); - _contextualFilter = new ContextualTargetingFilter(options, loggerFactory); + _contextualFilter = new ContextualTargetingFilter(options); _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); } /// - /// Performs a targeting evaluation using the current to determine if a feature should be enabled. + /// Performs a targeting evaluation using the current to determine if a feature flag should be enabled. /// /// The feature evaluation context. + /// The cancellation token to cancel the operation. /// Thrown if is null. - /// True if the feature is enabled, false otherwise. - public async Task EvaluateAsync(FeatureFilterEvaluationContext context) + /// True if the feature flag is enabled, false otherwise. + public async Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken) { if (context == null) { @@ -47,7 +49,7 @@ public async Task EvaluateAsync(FeatureFilterEvaluationContext context) // // Acquire targeting context via accessor - TargetingContext targetingContext = await _contextAccessor.GetContextAsync().ConfigureAwait(false); + TargetingContext targetingContext = await _contextAccessor.GetContextAsync(cancellationToken).ConfigureAwait(false); // // Ensure targeting can be performed @@ -60,7 +62,7 @@ public async Task EvaluateAsync(FeatureFilterEvaluationContext context) // // Utilize contextual filter for targeting evaluation - return await _contextualFilter.EvaluateAsync(context, targetingContext).ConfigureAwait(false); + return await _contextualFilter.EvaluateAsync(context, targetingContext, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFilterSettings.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFilterSettings.cs index 1fde45d7..3855238a 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFilterSettings.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFilterSettings.cs @@ -9,7 +9,7 @@ namespace Microsoft.FeatureManagement.FeatureFilters public class TargetingFilterSettings { /// - /// The audience that a feature configured to use the should be enabled for. + /// The audience that a feature flag configured to use the should be enabled for. /// public Audience Audience { get; set; } } diff --git a/test.cmd b/test.cmd deleted file mode 100644 index 6b02a979..00000000 --- a/test.cmd +++ /dev/null @@ -1,3 +0,0 @@ -cd /D "%~dp0" - -dotnet test tests\Tests.FeatureManagement\Tests.FeatureManagement.csproj --logger trx || exit /b 1 diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 00000000..8cf5fdc2 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,7 @@ +$ErrorActionPreference = "Stop" + +$dotnet = & "$PSScriptRoot/build/resolve-dotnet.ps1" + +& $dotnet test "$PSScriptRoot\tests\Tests.FeatureManagement\Tests.FeatureManagement.csproj" --logger trx + +exit $LASTEXITCODE \ No newline at end of file diff --git a/tests/Tests.FeatureManagement/ContextualTestAssigner.cs b/tests/Tests.FeatureManagement/ContextualTestAssigner.cs new file mode 100644 index 00000000..8065f170 --- /dev/null +++ b/tests/Tests.FeatureManagement/ContextualTestAssigner.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Tests.FeatureManagement +{ + class ContextualTestAssigner : IContextualFeatureVariantAssigner + { + public Func Callback { get; set; } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, IAccountContext appContext, CancellationToken cancellationToken) + { + return new ValueTask(Callback(variantAssignmentContext, appContext)); + } + } +} diff --git a/tests/Tests.FeatureManagement/ContextualTestFilter.cs b/tests/Tests.FeatureManagement/ContextualTestFilter.cs index 4bad3010..87241891 100644 --- a/tests/Tests.FeatureManagement/ContextualTestFilter.cs +++ b/tests/Tests.FeatureManagement/ContextualTestFilter.cs @@ -3,6 +3,7 @@ // using Microsoft.FeatureManagement; using System; +using System.Threading; using System.Threading.Tasks; namespace Tests.FeatureManagement @@ -11,7 +12,7 @@ class ContextualTestFilter : IContextualFeatureFilter { public Func ContextualCallback { get; set; } - public Task EvaluateAsync(FeatureFilterEvaluationContext context, IAccountContext accountContext) + public Task EvaluateAsync(FeatureFilterEvaluationContext context, IAccountContext accountContext, CancellationToken cancellationToken) { return Task.FromResult(ContextualCallback?.Invoke(context, accountContext) ?? false); } diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index af67fe8e..65399b17 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -9,12 +9,14 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Assigners; using Microsoft.FeatureManagement.FeatureFilters; using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -26,6 +28,8 @@ public class FeatureManagement private const string OffFeature = "OffFeature"; private const string ConditionalFeature = "ConditionalFeature"; private const string ContextualFeature = "ContextualFeature"; + private const string WithSuffixFeature = "WithSuffixFeature"; + private const string WithoutSuffixFeature = "WithoutSuffixFeature"; [Fact] public async Task ReadsConfiguration() @@ -37,7 +41,8 @@ public async Task ReadsConfiguration() services .AddSingleton(config) .AddFeatureManagement() - .AddFeatureFilter(); + .AddFeatureFilter() + .AddFeatureVariantAssigner(); ServiceProvider serviceProvider = services.BuildServiceProvider(); @@ -61,7 +66,7 @@ public async Task ReadsConfiguration() Assert.Equal("V1", evaluationContext.Parameters["P1"]); - Assert.Equal(ConditionalFeature, evaluationContext.FeatureName); + Assert.Equal(ConditionalFeature, evaluationContext.FeatureFlagName); return Task.FromResult(true); }; @@ -69,6 +74,139 @@ public async Task ReadsConfiguration() await featureManager.IsEnabledAsync(ConditionalFeature); Assert.True(called); + + IDynamicFeatureManager variantManager = serviceProvider.GetRequiredService(); + + IEnumerable featureVariantAssigners = serviceProvider.GetRequiredService>(); + + TestAssigner testAssigner = (TestAssigner)featureVariantAssigners.First(f => f is TestAssigner); + + called = false; + + testAssigner.Callback = (evaluationContext) => + { + called = true; + + Assert.Equal(2, evaluationContext.FeatureDefinition.Variants.Count()); + + Assert.Equal(Features.VariantFeature, evaluationContext.FeatureDefinition.Name); + + FeatureVariant defaultVariant = evaluationContext.FeatureDefinition.Variants.First(v => v.Default); + + FeatureVariant otherVariant = evaluationContext.FeatureDefinition.Variants.First(v => !v.Default); + + // + // default variant + Assert.Equal("V1", defaultVariant.Name); + + Assert.Equal("Ref1", defaultVariant.ConfigurationReference); + + // other variant + Assert.Equal("V2", otherVariant.Name); + + Assert.Equal("Ref2", otherVariant.ConfigurationReference); + + Assert.Equal("V1", otherVariant.AssignmentParameters["P1"]); + + return otherVariant; + }; + + string val = await variantManager.GetVariantAsync(Features.VariantFeature, CancellationToken.None); + + Assert.True(called); + + Assert.Equal("def", val); + } + + [Fact] + public async Task ReadsV1Configuration() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.v1.json").Build(); + + var services = new ServiceCollection(); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter() + .AddFeatureVariantAssigner(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + Assert.True(await featureManager.IsEnabledAsync(OnFeature, CancellationToken.None)); + + Assert.False(await featureManager.IsEnabledAsync(OffFeature, CancellationToken.None)); + + IEnumerable featureFilters = serviceProvider.GetRequiredService>(); + + // + // Sync filter + TestFilter testFeatureFilter = (TestFilter)featureFilters.First(f => f is TestFilter); + + bool called = false; + + testFeatureFilter.Callback = (evaluationContext) => + { + called = true; + + Assert.Equal("V1", evaluationContext.Parameters["P1"]); + + Assert.Equal(ConditionalFeature, evaluationContext.FeatureFlagName); + + return Task.FromResult(true); + }; + + await featureManager.IsEnabledAsync(ConditionalFeature, CancellationToken.None); + + Assert.True(called); + } + + [Fact] + public async Task AllowsSuffix() + { + /* + * Verifies a filter named ___Filter can be referenced with "___" or "___Filter" + */ + + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter() + .AddFeatureVariantAssigner(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + + IEnumerable featureFilters = serviceProvider.GetRequiredService>(); + + TestFilter testFeatureFilter = (TestFilter)featureFilters.First(f => f is TestFilter); + + bool called = false; + + testFeatureFilter.Callback = (evaluationContext) => + { + called = true; + + return Task.FromResult(true); + }; + + await featureManager.IsEnabledAsync(WithSuffixFeature, CancellationToken.None); + + Assert.True(called); + + called = false; + + await featureManager.IsEnabledAsync(WithoutSuffixFeature, CancellationToken.None); + + Assert.True(called); } [Fact] @@ -153,7 +291,7 @@ public async Task GatesFeatures() // // Enable 1/2 features - testFeatureFilter.Callback = ctx => Task.FromResult(ctx.FeatureName == Enum.GetName(typeof(Features), Features.ConditionalFeature)); + testFeatureFilter.Callback = ctx => Task.FromResult(ctx.FeatureFlagName == Features.ConditionalFeature); gateAllResponse = await testServer.CreateClient().GetAsync("gateAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("gateAny"); @@ -207,7 +345,7 @@ public async Task GatesRazorPageFeatures() // // Enable 1/2 features - testFeatureFilter.Callback = ctx => Task.FromResult(ctx.FeatureName == Enum.GetName(typeof(Features), Features.ConditionalFeature)); + testFeatureFilter.Callback = ctx => Task.FromResult(ctx.FeatureFlagName == Features.ConditionalFeature); gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny"); @@ -319,7 +457,7 @@ public async Task Targeting() IFeatureManager featureManager = serviceProvider.GetRequiredService(); - string targetingTestFeature = Enum.GetName(typeof(Features), Features.TargetingTestFeature); + string targetingTestFeature = Features.TargetingTestFeature; // // Targeted by user id @@ -359,6 +497,212 @@ public async Task Targeting() })); } + [Fact] + public async Task VariantTargeting() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .Configure(options => + { + options.IgnoreMissingFeatureFilters = true; + }); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IDynamicFeatureManager variantManager = serviceProvider.GetRequiredService(); + + // + // Targeted + Assert.Equal("def", await variantManager.GetVariantAsync( + Features.ContextualVariantTargetingFeature, + new TargetingContext + { + UserId = "Jeff" + }, + CancellationToken.None)); + + // + // Not targeted + Assert.Equal("abc", await variantManager.GetVariantAsync( + Features.ContextualVariantTargetingFeature, + new TargetingContext + { + UserId = "Patty" + }, + CancellationToken.None)); + } + + [Fact] + public async Task TargetingAssignmentPrecedence() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .Configure(options => + { + options.IgnoreMissingFeatureFilters = true; + }); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IDynamicFeatureManager variantManager = serviceProvider.GetRequiredService(); + + // + // Assigned variant by default rollout due to no higher precedence match + Assert.Equal("def", await variantManager.GetVariantAsync( + Features.PrecedenceTestingFeature, + new TargetingContext + { + UserId = "Patty" + }, + CancellationToken.None)); + + // + // Assigned variant by group due to higher precedence than default rollout + Assert.Equal("ghi", await variantManager.GetVariantAsync( + Features.PrecedenceTestingFeature, + new TargetingContext + { + UserId = "Patty", + Groups = new string[] + { + "Ring0" + } + }, + CancellationToken.None)); + + // + // Assigned variant by user name to higher precedence than default rollout, and group match + Assert.Equal("jkl", await variantManager.GetVariantAsync( + Features.PrecedenceTestingFeature, + new TargetingContext + { + UserId = "Jeff", + Groups = new string[] + { + "Ring0" + } + }, + CancellationToken.None)); + } + + [Fact] + public async Task AccumulatesAudience() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .Configure(options => + { + options.IgnoreMissingFeatureFilters = true; + }); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IDynamicFeatureManager variantManager = serviceProvider.GetRequiredService(); + + IFeatureFlagDefinitionProvider featureProvider = serviceProvider.GetRequiredService(); + + var occurences = new Dictionary(); + + int totalAssignments = 3000; + + // + // Test default rollout percentage accumulation + for (int i = 0; i < totalAssignments; i++) + { + string result = await variantManager.GetVariantAsync( + "AccumulatedTargetingFeature", + new TargetingContext + { + UserId = RandomHelper.GetRandomString(32) + }, + CancellationToken.None); + + if (!occurences.ContainsKey(result)) + { + occurences.Add(result, 1); + } + else + { + occurences[result]++; + } + } + + foreach (KeyValuePair occurence in occurences) + { + double expectedPercentage = double.Parse(occurence.Key); + + double tolerance = expectedPercentage * .25; + + double percentage = 100 * (double)occurence.Value / totalAssignments; + + Assert.True(percentage > expectedPercentage - tolerance); + + Assert.True(percentage < expectedPercentage + tolerance); + } + + occurences.Clear(); + + // + // Test Group rollout accumulation + for (int i = 0; i < totalAssignments; i++) + { + string result = await variantManager.GetVariantAsync( + "AccumulatedGroupsTargetingFeature", + new TargetingContext + { + UserId = RandomHelper.GetRandomString(32), + Groups = new string[] { "r", } + }, + CancellationToken.None); + + if (!occurences.ContainsKey(result)) + { + occurences.Add(result, 1); + } + else + { + occurences[result]++; + } + } + + foreach (KeyValuePair occurence in occurences) + { + double expectedPercentage = double.Parse(occurence.Key); + + double tolerance = expectedPercentage * .25; + + double percentage = 100 * (double)occurence.Value / totalAssignments; + + Assert.True(percentage > expectedPercentage - tolerance); + + Assert.True(percentage < expectedPercentage + tolerance); + } + } + [Fact] public async Task TargetingAccessor() { @@ -385,7 +729,7 @@ public async Task TargetingAccessor() IFeatureManager featureManager = serviceProvider.GetRequiredService(); - string beta = Enum.GetName(typeof(Features), Features.TargetingTestFeature); + string beta = Features.TargetingTestFeature; // // Targeted by user id @@ -443,6 +787,58 @@ public async Task UsesContext() Assert.True(await featureManager.IsEnabledAsync(ContextualFeature, context)); } + [Fact] + public async Task UsesContextVariants() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var serviceCollection = new ServiceCollection(); + + serviceCollection.AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + + ServiceProvider provider = serviceCollection.BuildServiceProvider(); + + ContextualTestAssigner contextualAssigner = (ContextualTestAssigner)provider + .GetRequiredService>().First(f => f is ContextualTestAssigner); + + contextualAssigner.Callback = (ctx, accountContext) => + { + foreach (FeatureVariant variant in ctx.FeatureDefinition.Variants) + { + var allowedAccounts = new List(); + + variant.AssignmentParameters.Bind("AllowedAccounts", allowedAccounts); + + if (allowedAccounts.Contains(accountContext.AccountId)) + { + return variant; + } + } + + return ctx.FeatureDefinition.Variants.FirstOrDefault(v => v.Default); + }; + + IDynamicFeatureManager variantManager = provider.GetRequiredService(); + + AppContext context = new AppContext(); + + context.AccountId = "NotEnabledAccount"; + + Assert.Equal("abc", await variantManager.GetVariantAsync( + Features.ContextualVariantFeature, + context, + CancellationToken.None)); + + context.AccountId = "abc"; + + Assert.Equal("def", await variantManager.GetVariantAsync( + Features.ContextualVariantFeature, + context, + CancellationToken.None)); + } + [Fact] public void LimitsFeatureFilterImplementations() { @@ -465,6 +861,28 @@ public void LimitsFeatureFilterImplementations() }); } + [Fact] + public void LimitsFeatureVariantAssignerImplementations() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var serviceCollection = new ServiceCollection(); + + Assert.Throws(() => + { + new ServiceCollection().AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + }); + + Assert.Throws(() => + { + new ServiceCollection().AddSingleton(config) + .AddFeatureManagement() + .AddFeatureVariantAssigner(); + }); + } + [Fact] public async Task ListsFeatures() { @@ -480,16 +898,29 @@ public async Task ListsFeatures() { IFeatureManager featureManager = provider.GetRequiredService(); - bool hasItems = false; + bool hasFeatureFlags = false; + + await foreach (string feature in featureManager.GetFeatureFlagNamesAsync(CancellationToken.None)) + { + hasFeatureFlags = true; + + break; + } + + Assert.True(hasFeatureFlags); + + IDynamicFeatureManager dynamicFeatureManager = provider.GetRequiredService(); + + bool hasDynamicFeatures = false; - await foreach (string feature in featureManager.GetFeatureNamesAsync()) + await foreach (string feature in dynamicFeatureManager.GetDynamicFeatureNamesAsync(CancellationToken.None)) { - hasItems = true; + hasDynamicFeatures = true; break; } - Assert.True(hasItems); + Assert.True(hasDynamicFeatures); } } @@ -508,7 +939,8 @@ public async Task ThrowsExceptionForMissingFeatureFilter() IFeatureManager featureManager = serviceProvider.GetRequiredService(); - FeatureManagementException e = await Assert.ThrowsAsync(async () => await featureManager.IsEnabledAsync(ConditionalFeature)); + FeatureManagementException e = await Assert.ThrowsAsync(async () => + await featureManager.IsEnabledAsync(ConditionalFeature, CancellationToken.None)); Assert.Equal(FeatureManagementError.MissingFeatureFilter, e.Error); } @@ -567,7 +999,11 @@ public async Task ThrowsForMissingFeatures() [Fact] public async Task CustomFeatureDefinitionProvider() { - FeatureDefinition testFeature = new FeatureDefinition + const string DynamicFeature = "DynamicFeature"; + + // + // Feature flag + FeatureFlagDefinition testFeature = new FeatureFlagDefinition { Name = ConditionalFeature, EnabledFor = new List() @@ -583,11 +1019,58 @@ public async Task CustomFeatureDefinitionProvider() } }; + // + // Dynamic feature + DynamicFeatureDefinition dynamicFeature = new DynamicFeatureDefinition + { + Name = DynamicFeature, + Assigner = "Test", + Variants = new List() + { + new FeatureVariant + { + Name = "V1", + AssignmentParameters = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary() + { + { "P1", "V1" } + }) + .Build(), + ConfigurationReference = "Ref1", + Default = true + }, + new FeatureVariant + { + Name = "V2", + AssignmentParameters = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary() + { + { "P2", "V2" } + }) + .Build(), + ConfigurationReference = "Ref2" + } + } + }; + var services = new ServiceCollection(); - services.AddSingleton(new InMemoryFeatureDefinitionProvider(new FeatureDefinition[] { testFeature })) + var definitionProvider = new InMemoryFeatureDefinitionProvider( + new FeatureFlagDefinition[] + { + testFeature + }, + new DynamicFeatureDefinition[] + { + dynamicFeature + }); + + services.AddSingleton(definitionProvider) + .AddSingleton(definitionProvider) + .AddSingleton(new ConfigurationBuilder().Build()) .AddFeatureManagement() - .AddFeatureFilter(); + .AddFeatureFilter() + .AddFeatureVariantAssigner(); ServiceProvider serviceProvider = services.BuildServiceProvider(); @@ -607,7 +1090,7 @@ public async Task CustomFeatureDefinitionProvider() Assert.Equal("V1", evaluationContext.Parameters["P1"]); - Assert.Equal(ConditionalFeature, evaluationContext.FeatureName); + Assert.Equal(ConditionalFeature, evaluationContext.FeatureFlagName); return Task.FromResult(true); }; @@ -615,6 +1098,43 @@ public async Task CustomFeatureDefinitionProvider() await featureManager.IsEnabledAsync(ConditionalFeature); Assert.True(called); + + IDynamicFeatureManager dynamicFeatureManager = serviceProvider.GetRequiredService(); + + IEnumerable featureAssigners = serviceProvider.GetRequiredService>(); + + // + // Sync filter + TestAssigner testFeatureVariantAssigner = (TestAssigner)featureAssigners.First(f => f is TestAssigner); + + called = false; + + testFeatureVariantAssigner.Callback = (assignmentContext) => + { + called = true; + + Assert.True(assignmentContext.FeatureDefinition.Variants.Count() == 2); + + FeatureVariant v1 = assignmentContext.FeatureDefinition.Variants.First(v => v.Name == "V1"); + + Assert.True(v1.Default); + + Assert.Equal("V1", v1.AssignmentParameters["P1"]); + + Assert.Equal("Ref1", v1.ConfigurationReference); + + FeatureVariant v2 = assignmentContext.FeatureDefinition.Variants.First(v => v.Name == "V2"); + + Assert.False(v2.Default); + + Assert.Equal("Ref2", v2.ConfigurationReference); + + return v1; + }; + + await dynamicFeatureManager.GetVariantAsync(DynamicFeature, CancellationToken.None); + + Assert.True(called); } [Fact] @@ -671,7 +1191,7 @@ public async Task ThreadsafeSnapshot() private static void DisableEndpointRouting(MvcOptions options) { -#if NET5_0 || NETCOREAPP3_1 +#if NET6_0 || NETCOREAPP3_1 // // Endpoint routing is disabled by default in .NET Core 2.1 since it didn't exist. options.EnableEndpointRouting = false; diff --git a/tests/Tests.FeatureManagement/Features.cs b/tests/Tests.FeatureManagement/Features.cs index ade2d13d..be136e16 100644 --- a/tests/Tests.FeatureManagement/Features.cs +++ b/tests/Tests.FeatureManagement/Features.cs @@ -3,12 +3,16 @@ // namespace Tests.FeatureManagement { - enum Features + static class Features { - TargetingTestFeature, - OnTestFeature, - OffTestFeature, - ConditionalFeature, - ConditionalFeature2 + public const string TargetingTestFeature = "TargetingTestFeature"; + public const string OnTestFeature = "OnTestFeature"; + public const string OffTestFeature = "OffTestFeature"; + public const string ConditionalFeature = "ConditionalFeature"; + public const string ConditionalFeature2 = "ConditionalFeature2"; + public const string VariantFeature = "VariantFeature"; + public const string ContextualVariantFeature = "ContextualVariantFeature"; + public const string ContextualVariantTargetingFeature = "ContextualVariantTargetingFeature"; + public const string PrecedenceTestingFeature = "PrecedenceTestingFeature"; } } diff --git a/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs b/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs index 91cbcefd..ee0cfa9b 100644 --- a/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs +++ b/tests/Tests.FeatureManagement/InMemoryFeatureDefinitionProvider.cs @@ -1,33 +1,57 @@ -using Microsoft.FeatureManagement; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement; using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; namespace Tests.FeatureManagement { - class InMemoryFeatureDefinitionProvider : IFeatureDefinitionProvider + class InMemoryFeatureDefinitionProvider : IFeatureFlagDefinitionProvider, IDynamicFeatureDefinitionProvider { - private IEnumerable _definitions; + private IEnumerable _featureFlagDefinitions; + private IEnumerable _dynamicFeatureDefinitions; - public InMemoryFeatureDefinitionProvider(IEnumerable featureDefinitions) + public InMemoryFeatureDefinitionProvider( + IEnumerable featureFlagDefinitions, + IEnumerable dynamicFeatureDefinitions) { - _definitions = featureDefinitions ?? throw new ArgumentNullException(nameof(featureDefinitions)); + _featureFlagDefinitions = featureFlagDefinitions ?? throw new ArgumentNullException(nameof(featureFlagDefinitions)); + _dynamicFeatureDefinitions = dynamicFeatureDefinitions ?? throw new ArgumentNullException(nameof(dynamicFeatureDefinitions)); } #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() + public async IAsyncEnumerable GetAllFeatureFlagDefinitionsAsync([EnumeratorCancellation] CancellationToken cancellationToken) #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { - foreach (FeatureDefinition definition in _definitions) + foreach (FeatureFlagDefinition definition in _featureFlagDefinitions) { yield return definition; } } - public Task GetFeatureDefinitionAsync(string featureName) + public Task GetFeatureFlagDefinitionAsync(string featureName, CancellationToken cancellationToken) { - return Task.FromResult(_definitions.FirstOrDefault(definitions => definitions.Name.Equals(featureName, StringComparison.OrdinalIgnoreCase))); + return Task.FromResult(_featureFlagDefinitions.FirstOrDefault(definitions => definitions.Name.Equals(featureName, StringComparison.OrdinalIgnoreCase))); + } + + public Task GetDynamicFeatureDefinitionAsync(string featureName, CancellationToken cancellationToken = default) + { + return Task.FromResult(_dynamicFeatureDefinitions.FirstOrDefault(definitions => definitions.Name.Equals(featureName, StringComparison.OrdinalIgnoreCase))); + } + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async IAsyncEnumerable GetAllDynamicFeatureDefinitionsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + foreach (DynamicFeatureDefinition definition in _dynamicFeatureDefinitions) + { + yield return definition; + } } } } diff --git a/tests/Tests.FeatureManagement/InvalidFeatureFilter.cs b/tests/Tests.FeatureManagement/InvalidFeatureFilter.cs index 0fab5063..cc42aaef 100644 --- a/tests/Tests.FeatureManagement/InvalidFeatureFilter.cs +++ b/tests/Tests.FeatureManagement/InvalidFeatureFilter.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using Microsoft.FeatureManagement; +using System.Threading; using System.Threading.Tasks; namespace Tests.FeatureManagement @@ -10,12 +11,12 @@ namespace Tests.FeatureManagement // Cannot implement more than one IFeatureFilter interface class InvalidFeatureFilter : IContextualFeatureFilter, IContextualFeatureFilter { - public Task EvaluateAsync(FeatureFilterEvaluationContext context, IAccountContext accountContext) + public Task EvaluateAsync(FeatureFilterEvaluationContext context, IAccountContext accountContext, CancellationToken cancellationToken) { return Task.FromResult(false); } - public Task EvaluateAsync(FeatureFilterEvaluationContext featureFilterContext, object appContext) + public Task EvaluateAsync(FeatureFilterEvaluationContext featureFilterContext, object appContext, CancellationToken cancellationToken) { return Task.FromResult(false); } @@ -25,12 +26,12 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext featureFilterCont // Cannot implement more than one IFeatureFilter interface class InvalidFeatureFilter2 : IFeatureFilter, IContextualFeatureFilter { - public Task EvaluateAsync(FeatureFilterEvaluationContext featureFilterContext, object appContext) + public Task EvaluateAsync(FeatureFilterEvaluationContext featureFilterContext, object appContext, CancellationToken cancellationToken) { return Task.FromResult(false); } - public Task EvaluateAsync(FeatureFilterEvaluationContext context) + public Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken) { return Task.FromResult(false); } diff --git a/tests/Tests.FeatureManagement/InvalidFeatureVariantAssigner.cs b/tests/Tests.FeatureManagement/InvalidFeatureVariantAssigner.cs new file mode 100644 index 00000000..d98f8028 --- /dev/null +++ b/tests/Tests.FeatureManagement/InvalidFeatureVariantAssigner.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement; +using System.Threading; +using System.Threading.Tasks; + +namespace Tests.FeatureManagement +{ + // + // Cannot implement more than one IFeatureVariantAssigner interface + class InvalidFeatureVariantAssigner : IContextualFeatureVariantAssigner, IContextualFeatureVariantAssigner + { + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, IAccountContext appContext, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, object appContext, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + } + + // + // Cannot implement more than one IFeatureVariantAssigner interface + class InvalidFeatureVariantAssigner2 : IFeatureVariantAssigner, IContextualFeatureVariantAssigner + { + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, object appContext, CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/tests/Tests.FeatureManagement/OnDemandTargetingContextAccessor.cs b/tests/Tests.FeatureManagement/OnDemandTargetingContextAccessor.cs index 7eb7e971..5ba376fb 100644 --- a/tests/Tests.FeatureManagement/OnDemandTargetingContextAccessor.cs +++ b/tests/Tests.FeatureManagement/OnDemandTargetingContextAccessor.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using Microsoft.FeatureManagement.FeatureFilters; +using System.Threading; using System.Threading.Tasks; namespace Tests.FeatureManagement @@ -10,7 +11,7 @@ class OnDemandTargetingContextAccessor : ITargetingContextAccessor { public TargetingContext Current { get; set; } - public ValueTask GetContextAsync() + public ValueTask GetContextAsync(CancellationToken cancellationToken) { return new ValueTask(Current); } diff --git a/tests/Tests.FeatureManagement/RandomHelper.cs b/tests/Tests.FeatureManagement/RandomHelper.cs new file mode 100644 index 00000000..e6bf13b2 --- /dev/null +++ b/tests/Tests.FeatureManagement/RandomHelper.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Text; + +namespace Tests.FeatureManagement +{ + class RandomHelper + { + private static Random s_random = new Random(); + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz"; + + public static string GetRandomString(int length) + { + var sb = new StringBuilder(); + + for (int i = 0; i < length; i++) + { + sb.Append(chars[s_random.Next(chars.Length) % chars.Length]); + } + + return sb.ToString(); + } + } +} diff --git a/tests/Tests.FeatureManagement/TestAssigner.cs b/tests/Tests.FeatureManagement/TestAssigner.cs new file mode 100644 index 00000000..260d8540 --- /dev/null +++ b/tests/Tests.FeatureManagement/TestAssigner.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.FeatureManagement; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Tests.FeatureManagement +{ + class TestAssigner : IFeatureVariantAssigner + { + public Func Callback { get; set; } + + public ValueTask AssignVariantAsync(FeatureVariantAssignmentContext variantAssignmentContext, CancellationToken cancellationToken) + { + return new ValueTask(Callback(variantAssignmentContext)); + } + } +} diff --git a/tests/Tests.FeatureManagement/TestFilter.cs b/tests/Tests.FeatureManagement/TestFilter.cs index d3db4225..6d71464f 100644 --- a/tests/Tests.FeatureManagement/TestFilter.cs +++ b/tests/Tests.FeatureManagement/TestFilter.cs @@ -3,6 +3,7 @@ // using Microsoft.FeatureManagement; using System; +using System.Threading; using System.Threading.Tasks; namespace Tests.FeatureManagement @@ -11,7 +12,7 @@ class TestFilter : IFeatureFilter { public Func> Callback { get; set; } - public Task EvaluateAsync(FeatureFilterEvaluationContext context) + public Task EvaluateAsync(FeatureFilterEvaluationContext context, CancellationToken cancellationToken) { return Callback?.Invoke(context) ?? Task.FromResult(false); } diff --git a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj index 2b4517bd..4ba1c989 100644 --- a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj +++ b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1;netcoreapp3.1;net5.0 + netcoreapp2.1;netcoreapp3.1;net6.0 false 8.0 @@ -25,10 +25,10 @@ - + - - + + diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index f033a400..b26dc6a4 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -1,68 +1,259 @@ { - "Logging": { - "LogLevel": { - "Default": "Warning" - } - }, - "AllowedHosts": "*", - "FeatureManagement": { - "OnTestFeature": true, - "OffTestFeature": false, - "TargetingTestFeature": { - "EnabledFor": [ - { - "Name": "Targeting", - "Parameters": { - "Audience": { - "Users": [ - "Jeff", - "Alicia" - ], - "Groups": [ - { - "Name": "Ring0", - "RolloutPercentage": 100 - }, - { - "Name": "Ring1", - "RolloutPercentage": 50 - } - ], - "DefaultRolloutPercentage": 20 + "FeatureFlags": { + "OnTestFeature": true, + "OffTestFeature": false, + "ConditionalFeature": { + "EnabledFor": [ + { + "Name": "Test", + "Parameters": { + "P1": "V1" } } - } - ] - }, - "ConditionalFeature": { - "EnabledFor": [ - { - "Name": "Test", - "Parameters": { - "P1": "V1" + ] + }, + "TargetingTestFeature": { + "EnabledFor": [ + { + "Name": "Targeting", + "Parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20 + } + } } - } - ] - }, - "ConditionalFeature2": { - "EnabledFor": [ - { - "Name": "Test" - } - ] + ] + }, + "ConditionalFeature2": { + "EnabledFor": [ + { + "Name": "Test" + } + ] + }, + "WithSuffixFeature": { + "EnabledFor": [ + { + "Name": "TestFilter", + "Parameters": { + } + } + ] + }, + "WithoutSuffixFeature": { + "EnabledFor": [ + { + "Name": "Test", + "Parameters": { + } + } + ] + }, + "ContextualFeature": { + "EnabledFor": [ + { + "Name": "ContextualTest", + "Parameters": { + "AllowedAccounts": [ + "abc" + ] + } + } + ] + } }, - "ContextualFeature": { - "EnabledFor": [ - { - "Name": "ContextualTest", - "Parameters": { - "AllowedAccounts": [ - "abc" - ] + "DynamicFeatures": { + "VariantFeature": { + "Assigner": "Test", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "P1": "V1" + } + } + ] + }, + "ContextualVariantFeature": { + "Assigner": "ContextualTest", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "AllowedAccounts": [ + "abc" + ] + } + } + ] + }, + "ContextualVariantTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Jeff" + ] + } + } + } + ] + }, + "AccumulatedTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Percentage15" + }, + { + "Name": "V2", + "ConfigurationReference": "Percentage35", + "AssignmentParameters": { + "Audience": { + "DefaultRolloutPercentage": 35 + } + } + }, + { + "Name": "V3", + "ConfigurationReference": "Percentage50", + "AssignmentParameters": { + "Audience": { + "DefaultRolloutPercentage": 50 + } + } + } + ] + }, + "AccumulatedGroupsTargetingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Percentage15" + }, + { + "Name": "V2", + "ConfigurationReference": "Percentage35", + "AssignmentParameters": { + "Audience": { + "Groups": [ + { + "Name": "r", + "RolloutPercentage": 35 + } + ], + "DefaultRolloutPercentage": 0 + } + } + }, + { + "Name": "V3", + "ConfigurationReference": "Percentage50", + "AssignmentParameters": { + "Audience": { + "Groups": [ + { + "Name": "r", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 0 + } + } } - } - ] + ] + }, + "PrecedenceTestingFeature": { + "Assigner": "Targeting", + "Variants": [ + { + "Default": true, + "Name": "V1", + "ConfigurationReference": "Ref1" + }, + { + "Name": "V2", + "ConfigurationReference": "Ref2", + "AssignmentParameters": { + "Audience": { + "DefaultRolloutPercentage": 100 + } + } + }, + { + "Name": "V3", + "ConfigurationReference": "Ref3", + "AssignmentParameters": { + "Audience": { + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + } + ] + } + } + }, + { + "Name": "V4", + "ConfigurationReference": "Ref4", + "AssignmentParameters": { + "Audience": { + "Users": [ + "Jeff" + ] + } + } + } + ] + } } - } + }, + "Ref1": "abc", + "Ref2": "def", + "Ref3": "ghi", + "Ref4": "jkl", + "Percentage15": 15, + "Percentage35": 35, + "Percentage50": 50 } diff --git a/tests/Tests.FeatureManagement/appsettings.v1.json b/tests/Tests.FeatureManagement/appsettings.v1.json new file mode 100644 index 00000000..93818779 --- /dev/null +++ b/tests/Tests.FeatureManagement/appsettings.v1.json @@ -0,0 +1,16 @@ +{ + "FeatureManagement": { + "OnTestFeature": true, + "OffTestFeature": false, + "ConditionalFeature": { + "EnabledFor": [ + { + "Name": "Test", + "Parameters": { + "P1": "V1" + } + } + ] + } + } +}