diff --git a/README.md b/README.md index 5e71c119..d251ebd0 100644 --- a/README.md +++ b/README.md @@ -1,1100 +1,25 @@ # .NET Feature Management +Feature management provides a way to develop and expose application functionality based on features. Many applications have special requirements when a new feature is developed such as when the feature should be enabled and under what conditions. This library provides a way to define these relationships, and also integrates into common .NET code patterns to make exposing these features possible. -[![Microsoft.FeatureManagement](https://img.shields.io/nuget/v/Microsoft.FeatureManagement?label=Microsoft.FeatureManagement)](https://www.nuget.org/packages/Microsoft.FeatureManagement) -[![Microsoft.FeatureManagement.AspNetCore](https://img.shields.io/nuget/v/Microsoft.FeatureManagement.AspNetCore?label=Microsoft.FeatureManagement.AspNetCore)](https://www.nuget.org/packages/Microsoft.FeatureManagement.AspNetCore) +## Get started -Feature flags provide a way for .NET and 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 are built on top of the .NET Core configuration system. Any .NET Core configuration provider is capable of acting as the backbone for feature flags. +[**Quickstart**](https://learn.microsoft.com/azure/azure-app-configuration/quickstart-feature-flag-dotnet): A quickstart guide is available to learn how to integrate feature flags from *Azure App Configuration* into your .NET applications. -Here are some of the benefits of using this library: +[**Feature Reference**](https://learn.microsoft.com/azure/azure-app-configuration/feature-management-dotnet-reference?pivots=preview-version): This document provides a full feature rundown. -* A common convention for feature management -* Low barrier-to-entry - * Built on `IConfiguration` - * Supports JSON file feature flag setup -* 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 - * Toggle on/off features through declarative configuration file - * Dynamically evaluate state of feature based on call to server -* API extensions for ASP.NET Core and MVC framework - * Routing - * Filters - * Action Attributes +[**API reference**](https://go.microsoft.com/fwlink/?linkid=2091700): This API reference details the API surface of the libraries contained within this repository. -**API Reference**: https://go.microsoft.com/fwlink/?linkid=2091700 +## Examples -## Index -* [Feature Flags](#feature-flags) - * [Feature Filters](#feature-filters) - * [Feature Flag Declaration](#feature-flag-declaration) -* [Consumption](#consumption) -* [ASP.NET Core Integration](#ASPNET-core-integration) -* [Implement a Feature Filter](#implementing-a-feature-filter) -* [Providing a Context For Feature Evaluation](#providing-a-context-for-feature-evaluation) -* [Built-in Feature Filters](#built-in-feature-filters) -* [Targeting](#targeting) - * [Targeting Exclusion](#targeting-exclusion) -* [Variants](#variants) - * [Variants in Dependency Injection](#variants-in-dependency-injection) -* [Telemetry](#telemetry) - * [Enabling Telemetry](#enabling-telemetry) - * [Custom Telemetry Publishers](#custom-telemetry-publishers) - * [Application Insights Telemetry Publisher](#application-insights-telemetry-publisher) -* [Caching](#caching) -* [Custom Feature Providers](#custom-feature-providers) +* [.NET Console App](./examples/ConsoleApp) +* [.NET Console App with Targeting](./examples/TargetingConsoleApp) +* [ASP.NET Core Web App (Razor Page)](./examples/RazorPages) +* [ASP.NET Core Web App (MVC)](./examples/FeatureFlagDemo) +* [Blazor Server App](./examples/BlazorServerApp) +* [ASP.NET Core Web App with Feature Flag Telemetry](./examples/EvaluationDataToApplicationInsights) -## 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. - -### 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 is 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. - -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. - -### 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. - -### 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. Feature flags are declared using the [`Microsoft Feature Management schema`](https://github.com/Azure/AppConfiguration/blob/main/docs/FeatureManagement/FeatureManagement.v2.0.0.schema.json). This schema is language agnostic in origin and is supported by all Microsoft feature management libraries. - - -Below we have an example of declaring feature flags in a json file. - -``` JavaScript -{ - "Logging": { - "LogLevel": { - "Default": "Warning" - } - }, - - // Define feature flags in a json file - "feature_management": { - "feature_flags": [ - { - "id": "FeatureT", - "enabled": false - }, - { - "id": "FeatureU", - "enabled": true, - "conditions": {} - }, - { - "id": "FeatureV", - "enabled": true, - "conditions": { - "client_filters": [ - { - "name": "Microsoft.TimeWindow", - "parameters": { - "Start": "Mon, 01 May 2023 13:59:59 GMT", - "End": "Sat, 01 July 2023 00:00:00 GMT" - } - } - ] - } - } - ] - } -} -``` - -The `feature_management` section of the json document is used by convention to load feature flag settings. Feature flag objects must be listed in the `feature_flags` array under the `feature_management` section. In the section above, we see that we have provided three different features. A feature flag has `id` and `enabled` properties. The `id` is the name used to identify and reference the feature flag. The `enabled` property specifies the enabled state of the feature flag. A feature is *OFF* if `enabled` is false. If `enabled` is true, then the state of the feature depends on the `conditions`. If there are no `conditions` then the feature is *ON*. If there are `conditions` and they are met then the feature is *ON*. If there are `conditions` and they are not met then the feature is *OFF*. The `conditions` property declares the conditions used to dynamically enabled the feature. Features define their feature filters in the `client_filters` array. `FeatureV` specifies a feature filter named `Microsoft.TimeWindow`. This is an example of a configurable feature filter. We can see in the example that the filter has a `Parameters` property. This is used to configure the filter. In this case, the start and end times for the feature to be active are configured. - -#### Requirement Type - -The `requirement_type` property of `conditions` is used to determine if the filters should use `Any` or `All` logic when evaluating the state of a feature. If `requirement_type` is not specified, the default value is `Any`. - -* `Any` means only one filter needs to evaluate to true for the feature to be enabled. -* `All` means every filter needs to evaluate to true for the feature to be enabled. - -A `requirement_type` of `All` changes the traversal. First, if there are no filters, the feature will be disabled. If there are filters, then the feature-filters are traversed until one of the filters decides that the feature should be disabled. If no filter indicates that the feature should be disabled, then it will be considered enabled. - -``` JavaScript -{ - "id": "FeatureW", - "enabled": true, - "conditions": { - "requirement_type": "All", - "client_filters": [ - { - "name": "Microsoft.TimeWindow", - "parameters": { - "Start": "Mon, 01 May 2023 13:59:59 GMT", - "End": "Sat, 01 Jul 2023 00:00:00 GMT" - } - }, - { - "name": "Microsoft.Percentage", - "parameters": { - "Value": "50" - } - } - ] - } -} -``` - -In the above example, `FeatureW` specifies a `requirement_type` of `All`, meaning all of it's filters must evaluate to true for the feature to be enabled. In this case, the feature will be enabled for 50% of users during the specified time window. - -### .NET Feature Management schema - -In previous versions, the primary schema for the feature management library was the [.NET feature management schema](./schemas/FeatureManagement.Dotnet.v1.0.0.schema.json). Starting from v4.0.0, new features including variants and telemetry will not be supported for the .NET feature management schema. - -## Consumption - -The basic form of feature management is checking if a feature flag is enabled and then performing actions based on the result. This is done through the `IFeatureManager`'s `IsEnabledAsync` method. - -``` C# -… -IFeatureManager featureManager; -… -if (await featureManager.IsEnabledAsync("FeatureX")) -{ - // Do something -} -``` - -### Service Registration - -Feature management relies on .NET Core dependency injection. We can register the feature management services using standard conventions. - -``` C# -using Microsoft.FeatureManagement; - -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddFeatureManagement(); - } -} -``` - -By default, the feature manager retrieves feature flag configuration from the `feature_management` section of the .NET Core configuration data. If the `feature_management` section does not exist, the configuration will be considered empty. - -**Note:** You can also specify that feature flag configuration should be retrieved from a different configuration section by passing the section to `AddFeatureManagement`. The following example tells the feature manager to read from a different section called "MyFeatureFlags" instead: - -``` C# -services.AddFeatureManagement(configuration.GetSection("MyFeatureFlags")); -``` - -### Dependency Injection - -When using the feature management library with MVC, the `IFeatureManager` can be obtained through dependency injection. - -``` C# -public class HomeController : Controller -{ - private readonly IFeatureManager _featureManager; - - public HomeController(IFeatureManager featureManager) - { - _featureManager = featureManager; - } -} -``` - -### Scoped Feature Management Services - -The `AddFeatureManagement` method adds feature management services as singletons within the application, but there are scenarios where it may be necessary for feature management services to be added as scoped services instead. For example, users may want to use feature filters which consume scoped services for context information. In this case, the `AddScopedFeatureManagement` method should be used instead. This will ensure that feature management services, including feature filters, are added as scoped services. - -``` C# -services.AddScopedFeatureManagement(); -``` - -## 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. - -``` C# -[FeatureGate("FeatureX")] -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("FeatureX")] -public IActionResult Index() -{ - return View(); -} -``` - -The `Index` MVC action above requires "FeatureX" to be enabled before it can be executed. - -### 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. - -``` C# -public interface IDisabledFeaturesHandler -{ - Task HandleDisabledFeature(IEnumerable features, ActionExecutingContext context); -} -``` - -### View - -In MVC views `` tags can be used to conditionally render content based on whether a feature is enabled or whether specific variant of a feature is assigned. For more information about variants, please refer to the [variants](./README.md#Variants) section. - -``` HTML+Razor - -

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

-
-``` - -``` HTML+Razor - -

This can only be seen if variant 'Alpha' of 'FeatureX' is assigned.

-
-``` - -You can also negate the tag helper evaluation to display content when a feature is disabled or when a variant is not assigned, by setting `negate="true"`. - -``` HTML+Razor - -

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

-
-``` - -``` HTML+Razor - -

This can only be seen if variant 'Alpha' of 'FeatureX' is not assigned.

-
-``` - -The `` tag can reference multiple features/variants by specifying a comma separated list of features/variants in the `name`/`variant` attribute. - -``` HTML+Razor - -

This can only be seen if 'FeatureX' and 'FeatureY' are enabled.

-
-``` - -``` HTML+Razor - -

This can only be seen if variant 'Alpha' or 'Beta' of 'FeatureX' is assigned.

-
-``` - -**Note:** if `variant` is specified, only *one* feature should be specified. - -By default, all listed features must be enabled for the feature tag to be rendered. This behavior can be overridden by adding the `requirement` attribute as seen in the example below. - -**Note:** If a `requirement` of `And` is used in conjunction with `variant` an error will be thrown, as multiple variants can never be assigned. - -``` HTML+Razor - -

This can only be seen if either 'FeatureX' or 'FeatureY' or both are 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 -``` - -### 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. -The feature management pipeline supports async MVC Action filters, which implement `IAsyncActionFilter`. - -``` C# -services.AddMvc(o => -{ - o.Filters.AddForFeature("FeatureX"); -}); -``` - -The code above adds an MVC filter named `SomeMvcFilter`. This filter is only triggered within the MVC pipeline if the feature it specifies, "FeatureX", 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. - -``` C# -[FeatureGate("FeatureX")] -public class IndexModel : PageModel -{ - public void OnGet() - { - } -} -``` - -The code above sets up a Razor page to require the "FeatureX" to be enabled. If the feature is not enabled, the page will generate an HTTP 404 (NotFound) result. - -When used on Razor pages, the `FeatureGateAttribute` must be placed on the page handler type. It cannot be placed on individual handler methods. - -### Application building - -The feature management library can be used to add application branches and middleware that execute conditionally based on feature state. - -``` C# -app.UseMiddlewareForFeature("FeatureX"); -``` - -With the above call, the application adds a middleware component that only appears in the request pipeline if the feature "FeatureX" is enabled. If the feature 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. - -``` C# -app.UseForFeature(featureName, appBuilder => -{ - appBuilder.UseMiddleware(); -}); -``` - -## Implementing a Feature Filter - -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. - -The following snippet demonstrates how to add a customized feature filter `MyCriteriaFilter`. - -``` C# -services.AddFeatureManagement() - .AddFeatureFilter(); -``` - -Feature filters are registered by calling `AddFeatureFilter` on the `IFeatureManagementBuilder` returned from `AddFeatureManagement`. 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. - -**Note:** When filters are referenced in feature flag settings (e.g. appsettings.json), the _Filter_ part of the type name should be omitted. Please refer to the `Filter Alias Attribute` section below for more details. - -### 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`. - -``` C# -public class FeatureFilterEvaluationContext -{ - /// - /// The name of the feature being evaluated. - /// - public string FeatureName { get; set; } - - /// - /// The settings provided for the feature filter to use when evaluating whether the feature 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. - -``` C# -[FilterAlias("Browser")] -public class BrowserFilter : IFeatureFilter -{ - … - - public Task EvaluateAsync(FeatureFilterEvaluationContext context) - { - BrowserFilterSettings settings = context.Parameters.Get() ?? new BrowserFilterSettings(); - - // - // Here we would use the settings and see if the request was sent from any of BrowserFilterSettings.AllowedBrowsers - } -} -``` - -### Filter Alias Attribute - -When a feature filter is registered to be used for a feature flag, the alias used in configuration is the name of the feature filter type with the _Filter_ suffix, if any, removed. For example, `MyCriteriaFilter` would be referred to as _MyCriteria_ in configuration. - -``` JavaScript -"conditions": { - "client_filters": [ - { - "name": "MyCriteria" - } - ] -} -``` -This can be overridden through the use of the `FilterAliasAttribute`. A feature filter can be decorated with this attribute to declare the name that should be used in configuration to reference this feature filter within a feature flag. - -### 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. - -``` C# -services.Configure(options => -{ - options.IgnoreMissingFeatureFilters = true; -}); -``` - -### Using HttpContext - -Feature filters can evaluate whether a feature should be enabled based on 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 -{ - private readonly IHttpContextAccessor _httpContextAccessor; - - public BrowserFilter(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); - } -} -``` - -The `IHttpContextAccessor` must be added to the dependency injection container on startup for it to be available. It can be registered in the `IServiceCollection` using the following method. - -``` C# -public void ConfigureServices(IServiceCollection services) -{ - … - services.TryAddSingleton(); - … -} -``` - -**Advanced:** `IHttpContextAccessor`/`HttpContext` should not be used in the Razor components of server-side Blazor apps. [The recommended approach](https://learn.microsoft.com/en-us/aspnet/core/blazor/security/server/interactive-server-side-rendering?view=aspnetcore-7.0#ihttpcontextaccessorhttpcontext-in-razor-components) for passing http context in Blazor apps is to copy the data into a scoped service. For Blazor apps, `AddScopedFeatureManagement` should be used to register the feature management services. -Please refer to the `Scoped Feature Management Services` section for more details. - -## 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. - -``` C# -MyAppContext context = new MyAppContext -{ - AccountId = current.Id; -} - -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. - -``` C# -public interface IAccountContext -{ - string AccountId { get; set; } -} - -[FilterAlias("AccountId")] -class AccountIdFilter : IContextualFeatureFilter -{ - public Task EvaluateAsync(FeatureFilterEvaluationContext featureEvaluationContext, IAccountContext accountId) - { - // - // Evaluate if the feature should be on with the help of the provided 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. 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`. - -### Using Contextual and Non-contextual Filters With the Same Alias - -Filters of `IFeatureFilter` and `IContextualFeatureFilter` can share the same alias. Specifically, you can have one filter alias shared by 0 or 1 `IFeatureFilter` and 0 or N `IContextualFeatureFilter`, so long as there is at most one applicable filter for `ContextType`. - -The following passage describes the process of selecting a filter when contextual and non-contextual filters of the same name are registered in an application. - -Let's say you have a non-contextual filter called `FilterA` and two contextual filters `FilterB` and FilterC which accept `TypeB` and `TypeC` contexts respectively. All three filters share the same alias `SharedFilterName`. - -You also have a feature flag `MyFeature` which uses the feature filter `SharedFilterName` in its configuration. - -If all of three filters are registered: -* When you call IsEnabledAsync("MyFeature"), the `FilterA` will be used to evaluate the feature flag. -* When you call IsEnabledAsync("MyFeature", context), if context's type is `TypeB`, `FilterB` will be used. If context's type is `TypeC`, `FilterC` will be used. -* When you call IsEnabledAsync("MyFeature", context), if context's type is `TypeF`, `FilterA` will be used. - -## Built-In Feature Filters - -There a few feature filters that come with the `Microsoft.FeatureManagement` package: `PercentageFilter`, `TimeWindowFilter`, `ContextualTargetingFilter` and `TargetingFilter`. All filters, except for the `TargetingFilter`, are added automatically when feature management is registered. The `TargetingFilter` is added with the `WithTargeting` method that is detailed in the `Targeting` section below. - -Each of the built-in feature filters have their own parameters. Here is the list of feature filters along with examples. - -### Microsoft.Percentage - -This filter provides the capability to enable a feature based on a set percentage. - -``` JavaScript -"conditions": { - "client_filters": [ - { - "name": "Microsoft.Percentage", - "parameters": { - "Value": 50 - } - } - ] -} -``` - -### 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. - -``` JavaScript -"conditions": { - "client_filter": [ - { - "name": "Microsoft.TimeWindow", - "parameters": { - "Start": "Wed, 01 May 2019 13:59:59 GMT", - "End": "Mon, 01 Jul 2019 00:00:00 GMT" - } - } - ] -} -``` - -### 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, excluded 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 exclusion section, either directly or if the user is in an excluded group, the feature will be disabled. Otherwise, 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. - -``` JavaScript -"conditions": { - "client_filters": [ - { - "name": "Microsoft.Targeting", - "parameters": { - "Audience": { - "Users": [ - "Jeff", - "Alicia" - ], - "Groups": [ - { - "Name": "Ring0", - "RolloutPercentage": 100 - }, - { - "Name": "Ring1", - "RolloutPercentage": 50 - } - ], - "DefaultRolloutPercentage": 20, - "Exclusion": { - "Users": [ - "Ross" - ], - "Groups": [ - "Ring2" - ] - } - } - } - } - ] -} -``` - -### Feature Filter Alias Namespaces - -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'. - -## 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, excluded 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. - -The following steps demonstrate an example of a progressive rollout for a new 'Beta' feature: - -1. Individual users Jeff and Alicia are granted access to the Beta -2. Another user, Mark, asks to opt-in and is included. -3. Twenty percent of a group known as "Ring1" users are included in the Beta. -5. The number of "Ring1" users included in the beta is bumped up to 100 percent. -5. Five percent of the user base is included in the beta. -6. The rollout percentage is bumped up to 100 percent and the feature is completely rolled out. - -This strategy for rolling out a feature is built-in to the library through the included [Microsoft.Targeting](./README.md#MicrosoftTargeting) feature filter. - -### Targeting in a Web Application - -An example web application that uses the targeting feature filter is available in the [FeatureFlagDemo](./examples/FeatureFlagDemo) example project. - -To begin using the `TargetingFilter` in an application it must be added to the application's service collection just as any other feature filter. Unlike other built-in filters, the `TargetingFilter` relies on another service to be added to the application's service collection. That service is an `ITargetingContextAccessor`. - -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.AddFeatureManagement() - .WithTargeting(); -``` - -The targeting context accessor and `TargetingFilter` are registered by calling `WithTargeting` on the `IFeatureManagementBuilder`. - -#### ITargetingContextAccessor - -To use the `TargetingFilter` in a web application, an implementation of `ITargetingContextAccessor` is required. This is because when a targeting evaluation is being performed, information such as what user is currently being evaluated is needed. This information is known as the targeting context. Different web applications may extract this information from different places. Some common examples of where an application may pull the targeting context are the request's HTTP context or a database. - -An example that extracts targeting context information from the application's HTTP context is included in the [FeatureFlagDemo](./examples/FeatureFlagDemo/HttpContextTargetingContextAccessor.cs) example project. This method relies on the use of `IHttpContextAccessor` which is discussed [here](./README.md#Using-HttpContext). - -### Targeting in a Console Application - -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 into 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.` - -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 -TargetingContext targetingContext = new TargetingContext -{ - UserId = userId, - Groups = groups; -} - -await fm.IsEnabledAsync(featureName, targetingContext); -``` - -The `ContextualTargetingFilter` still uses the feature filter alias [Microsoft.Targeting](./README.md#MicrosoftTargeting), so the configuration for this filter is consistent with what is mentioned in that section. - -An example that uses the `ContextualTargetingFilter` in a console application is available in the [TargetingConsoleApp](./examples/TargetingConsoleApp) example project. - -### Targeting Evaluation Options - -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 Exclusion - -When defining an Audience, users and groups can be excluded from the audience. This is useful when a feature is being rolled out to a group of users, but a few users or groups need to be excluded from the rollout. Exclusion is defined by adding a list of users and groups to the `Exclusion` property of the audience. -``` JavaScript -"Audience": { - "Users": [ - "Jeff", - "Alicia" - ], - "Groups": [ - { - "Name": "Ring0", - "RolloutPercentage": 100 - } - ], - "DefaultRolloutPercentage": 0 - "Exclusion": { - "Users": [ - "Mark" - ] - } -} -``` - -In the above example, the feature will be enabled for users named `Jeff` and `Alicia`. It will also be enabled for users in the group named `Ring0`. However, if the user is named `Mark`, the feature will be disabled, regardless of if they are in the group `Ring0` or not. Exclusions take priority over the rest of the targeting filter. - -## Variants - -When new features are added to an application, there may come a time when a feature has multiple different proposed design options. A common solution for deciding on a design is some form of A/B testing, which involves providing a different version of the feature to different segments of the user base and choosing a version based on user interaction. In this library, this functionality is enabled by representing different configurations of a feature with variants. - -Variants enable a feature flag to become more than a simple on/off flag. A variant represents a value of a feature flag that can be a string, a number, a boolean, or even a configuration object. A feature flag that declares variants should define under what circumstances each variant should be used, which is covered in greater detail in the [Allocating Variants](./README.md#allocating-variants) section. - -``` C# -public class Variant -{ - /// - /// The name of the variant. - /// - public string Name { get; set; } - - /// - /// The configuration of the variant. - /// - public IConfigurationSection Configuration { get; set; } -} -``` - -### Getting Variants - -For each feature, a variant can be retrieved using the `IVariantFeatureManager`'s `GetVariantAsync` method. - -``` C# -… -IVariantFeatureManager featureManager; -… -Variant variant = await featureManager.GetVariantAsync(MyFeatureFlags.FeatureU, CancellationToken.None); - -IConfigurationSection variantConfiguration = variant.Configuration; - -// Do something with the resulting variant and its configuration -``` - -Once a variant is retrieved, the configuration of a variant can be used directly as an `IConfigurationSection` from the variant's `Configuration` property. Another option is to bind the configuration to an object using .NET's configuration binding pattern. - -``` C# -IConfigurationSection variantConfiguration = variant.Configuration; - -MyFeatureSettings settings = new MyFeatureSettings(); - -variantConfiguration.Bind(settings); -``` - -The variant returned is dependent on the user currently being evaluated, and that information is obtained from an instance of `TargetingContext`. This context can either be passed in when calling `GetVariantAsync` or it can be automatically retrieved from an implementation of [`ITargetingContextAccessor`](#itargetingcontextaccessor) if one is registered. - -### Variant Feature Flag Declaration - -Compared to normal feature flags, variant feature flags have two additional properties: `variants` and `allocation`. The `variants` property is an array that contains the variants defined for this feature. The `allocation` property defines how these variants should be allocated for the feature. Just like declaring normal feature flags, you can set up variant feature flags in a json file. Here is an example of a variant feature flag. - -``` javascript - -{ - "feature_management": { - "feature_flags": [ - { - "id": "MyVariantFeatureFlag", - "enabled": true, - "allocation": { - "default_when_enabled": "Small", - "group": [ - { - "variant": "Big", - "groups": [ - "Ring1" - ] - } - ] - }, - "variants": [ - { - "name": "Big" - }, - { - "name": "Small" - } - ] - } - ] - } -} - -``` - -For more details about how to configure variant feature flags, please see [here](./schemas/FeatureManagement.Dotnet.v2.0.0.schema.json). - -#### Defining Variants - -Each variant has two properties: a name and a configuration. The name is used to refer to a specific variant, and the configuration is the value of that variant. The configuration can be set using either the `configuration_reference` or `configuration_value` properties. `configuration_reference` is a string path that references a section of the current configuration that contains the feature flag declaration. `configuration_value` is an inline configuration that can be a string, number, boolean, or configuration object. If both are specified, `configuration_value` is used. If neither are specified, the returned variant's `Configuration` property will be null. - -A list of all possible variants is defined for each feature under the `variants` property. - -``` javascript -{ - "feature_management": { - "feature_flags": [ - { - "id": "MyVariantFeatureFlag", - "variants": [ - { - "name": "Big", - "configuration_reference": "ShoppingCart:Big" - }, - { - "name": "Small", - "configuration_value": { - "Size": 300 - } - } - ] - } - ] - }, - - "ShoppingCart": { - "Big": { - "Size": 600, - "Color": "green" - }, - "Small": { - "Size": 300, - "Color": "gray" - } - } -} -``` - -#### Allocating Variants - -The process of allocating a feature's variants is determined by the `allocation` property of the feature. - -``` javascript -"allocation": { - "default_when_enabled": "Small", - "default_when_disabled": "Small", - "user": [ - { - "variant": "Big", - "users": [ - "Marsha" - ] - } - ], - "group": [ - { - "variant": "Big", - "groups": [ - "Ring1" - ] - } - ], - "percentile": [ - { - "variant": "Big", - "from": 0, - "to": 10 - } - ], - "seed": "13973240" -}, -"variants": [ - { - "name": "Big", - "configuration_reference": "ShoppingCart:Big" - }, - { - "name": "Small", - "configuration_value": "300px" - } -] -``` - -The `allocation` setting of a feature flag has the following properties: - -| Property | Description | -| ---------------- | ---------------- | -| `default_when_disabled` | Specifies which variant should be used when a variant is requested while the feature is considered disabled. | -| `default_when_enabled` | Specifies which variant should be used when a variant is requested while the feature is considered enabled and no other variant was assigned to the user. | -| `user` | Specifies a variant and a list of users to whom that variant should be assigned. | -| `group` | Specifies a variant and a list of groups. The variant will be assigned if the user is in at least one of the groups. | -| `percentile` | Specifies a variant and a percentage range the user's calculated percentage has to fit into for that variant to be assigned. | -| `seed` | The value which percentage calculations for `percentile` are based on. The percentage calculation for a specific user will be the same across all features if the same `seed` value is used. If no `seed` is specified, then a default seed is created based on the feature name. | - -In the above example, if the feature is not enabled, the feature manager will assign the variant marked as `default_when_disabled` to the current user, which is `Small` in this case. - -If the feature is enabled, the feature manager will check the `user`, `group`, and `percentile` allocations in that order to assign a variant. For this particular example, if the user being evaluated is named `Marsha`, in the group named `Ring1`, or the user happens to fall between the 0 and 10th percentile, then the specified variant is assigned to the user. In this case, all of these would return the `Big` variant. If none of these allocations match, the user is assigned the `default_when_enabled` variant, which is `Small`. - -Allocation logic is similar to the [Microsoft.Targeting](./README.md#MicrosoftTargeting) feature filter, but there are some parameters that are present in targeting that aren't in allocation, and vice versa. The outcomes of targeting and allocation are not related. - -**Note:** To allow allocating feature variants, you need to register `ITargetingContextAccessor`. This can be done by calling the `WithTargeting` method. - -### Overriding Enabled State with a Variant - -You can use variants to override the enabled state of a feature flag. This gives variants an opportunity to extend the evaluation of a feature flag. If a caller is checking whether a flag that has variants is enabled, the feature manager will check if the variant assigned to the current user is set up to override the result. This is done using the optional variant property `status_override`. By default, this property is set to `None`, which means the variant doesn't affect whether the flag is considered enabled or disabled. Setting `status_override` to `Enabled` allows the variant, when chosen, to override a flag to be enabled. Setting `status_override` to `Disabled` provides the opposite functionality, therefore disabling the flag when the variant is chosen. A feature with a `Status` of `Disabled` cannot be overridden. - -If you are using a feature flag with binary variants, the `status_override` property can be very helpful. It allows you to continue using APIs like `IsEnabledAsync` and `FeatureGateAttribute` in your application, all while benefiting from the new features that come with variants, such as percentile allocation and seed. - -``` javascript -{ - "id": "MyVariantFeatureFlag", - "enabled": true, - "allocation": { - "percentile": [ - { - "variant": "On", - "from": 10, - "to": 20 - } - ], - "default_when_enabled": "Off", - "seed": "Enhanced-Feature-Group" - }, - "variants": [ - { - "name": "On" - }, - { - "name": "Off", - "status_override": "Disabled" - } - ] -} -``` - -In the above example, the feature is always enabled. If the current user is in the calculated percentile range of 10 to 20, then the `On` variant is returned. Otherwise, the `Off` variant is returned and because `status_override` is equal to `Disabled`, the feature will now be considered disabled. - -### Variants in Dependency Injection - -Variant feature flags can be used in conjunction with dependency injection to surface different implementations of a service for different users. This is accomplished through the use of the `IVariantServiceProvider` interface. - -``` C# -IVariantServiceProvider algorithmServiceProvider; -... - -IAlgorithm forecastAlgorithm = await algorithmServiceProvider.GetServiceAsync(cancellationToken); -``` - -In the snippet above, the `IVariantServiceProvider` will retrieve an implementation of `IAlgorithm` from the dependency injection container. The chosen implementation is dependent upon: -* The feature flag that the `IAlgorithm` service was registered with. -* The allocated variant for that feature. - -The `IVariantServiceProvider` is made available to the application by calling `IFeatureManagementBuilder.WithVariantService(string featureName)`. See below for an example. - -``` C# -services.AddFeatureManagement() - .WithVariantService("ForecastAlgorithm"); -``` - -The call above makes `IVariantServiceProvider` available in the service collection. Implementation(s) of `IAlgorithm` must be added separately via an add method such as `services.AddSingleton()`. The implementation of `IAlgorithm` that the `IVariantServiceProvider` uses depends on the `ForecastAlgorithm` variant feature flag. If no implementation of `IAlgorithm` is added to the service collection, then the `IVariantServiceProvider.GetServiceAsync()` will return a task with a *null* result. - -``` javascript -{ - // The example variant feature flag - "id": "ForecastAlgorithm", - "enabled": true, - "variants": [ - { - "Name": "AlgorithmBeta" - }, - ... - ] -} -``` - -#### Variant Service Alias Attribute - -``` C# -[VariantServiceAlias("Beta")] -public class AlgorithmBeta : IAlgorithm -{ - ... -} -``` - -The variant service provider will use the type names of implementations to match the allocated variant. If a variant service is decorated with the `VariantServiceAliasAttribute`, the name declared in this attribute should be used in configuration to reference this variant service. - -## Telemetry - -When a feature flag change is deployed, it is often important to analyze its effect on an application. For example, here are a few questions that may arise: - -* Are my flags enabled/disabled as expected? -* Are targeted users getting access to a certain feature as expected? -* Which variant is a particular user seeing? - - -These types of questions can be answered through the emission and analysis of feature flag evaluation events. This library supports emitting these events through telemetry publishers. One or many telemetry publishers can be registered to publish events whenever feature flags are evaluated. - -### Enabling Telemetry - -By default, feature flags will not have telemetry emitted. To publish telemetry for a given feature flag, the flag *MUST* declare that it is enabled for telemetry emission. - -For flags defined in `appsettings.json`, that is done by using the `telemetry` property on feature flags. - -``` javascript -{ - "feature_management": { - "feature_flags": [ - { - "id": "MyFeatureFlag", - "enabled": true, - "telemetry": { - "enabled": true - } - } - ] - } -} -``` - -The appsettings snippet above defines a feature flag named `MyFeatureFlag` that is enabled for telemetry. This is indicated by the `telemetry` object which sets `enabled` to true. The value of the `enabled` property must be `true` to publish telemetry for the flag. - -The `telemetry` section of a feature flag has the following properties: - -| Property | Description | -| ---------------- | ---------------- | -| `enabled` | Specifies whether telemetry should be published for the feature flag. | -| `metadata` | A collection of key-value pairs, modeled as a dictionary, that can be used to attach custom metadata about the feature flag to evaluation events. | - -### Custom Telemetry Publishers - -Custom handling of feature flag telemetry is made possible by implementing an `ITelemetryPublisher` and registering it in the feature manager. Whenever a feature flag that has telemetry enabled is evaluated the registered telemetry publisher will get a chance to publish the corresponding evaluation event. - -``` C# -public interface ITelemetryPublisher -{ - ValueTask PublishEvent(EvaluationEvent evaluationEvent, CancellationToken cancellationToken); -} -``` - -The `EvaluationEvent` type can be found [here](./src/Microsoft.FeatureManagement/Telemetry/EvaluationEvent.cs) for reference. - -Registering telemetry publishers is done when calling `AddFeatureManagement()`. Here is an example setting up feature management to emit telemetry with an implementation of `ITelemetryPublisher` called `MyTelemetryPublisher`. - -``` C# -builder.services - .AddFeatureManagement() - .AddTelemetryPublisher(); -``` - -### Application Insights Telemetry Publisher - -The `Microsoft.FeatureManagement.Telemetry.ApplicationInsights` package provides a built-in telemetry publisher implementation that sends feature flag evaluation data to [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview). To take advantage of this, add a reference to the package and register the Application Insights telemetry publisher as shown below. - -``` C# -builder.services - .AddFeatureManagement() - .AddTelemetryPublisher(); -``` - -**Note:** The base `Microsoft.FeatureManagement` package does not include this telemetry publisher. - -An example of its usage can be found in the [EvaluationDataToApplicationInsights](./examples/EvaluationDataToApplicationInsights) example. - -#### Prerequisite - -This telemetry publisher depends on Application Insights already being [setup](https://learn.microsoft.com/azure/azure-monitor/app/asp-net-core#enable-application-insights-server-side-telemetry-no-visual-studio) and registered as an application service. For example, that is done [here](https://github.com/microsoft/FeatureManagement-Dotnet/blob/f125d32a395f560d8d13d50d7f11a69d6ca78499/examples/EvaluationDataToApplicationInsights/Program.cs#L20C9-L20C17) in the example application. - -## 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. - -## Custom Feature Providers - -Implementing a custom feature provider enables 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. - -``` C# -public interface IFeatureDefinitionProvider -{ - Task GetFeatureDefinitionAsync(string featureName); - - IAsyncEnumerable GetAllFeatureDefinitionsAsync(); -} -``` - -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`. - -``` C# -services.AddSingleton() - .AddFeatureManagement() -``` - -# Contributing +## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us diff --git a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj index c60a6ef7..04878e63 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj +++ b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj @@ -22,8 +22,10 @@ Microsoft.FeatureManagement.AspNetCore provides a way to develop and expose application functionality based on features. Many applications have special requirements when a new feature is developed such as when the feature should be enabled and under what conditions. This library provides a way to define these relationships, and also integrates into common ASP.NET Core code patterns to make exposing these features possible. Microsoft Microsoft - https://licenses.nuget.org/MIT - https://github.com/Azure/AppConfiguration + https://github.com/microsoft/FeatureManagement-Dotnet + https://github.com/microsoft/FeatureManagement-Dotnet.git + git + MIT Release notes can be found at https://aka.ms/MicrosoftFeatureManagementReleaseNotes Microsoft FeatureManagement FeatureFlags AzureAppConfiguration aspnetcore https://aka.ms/AzureAppConfigurationPackageIcon diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index c4829fce..19004d6d 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -50,12 +50,12 @@ public ConfigurationFeatureDefinitionProvider(IConfiguration configuration) /// /// The option that controls the behavior when "FeatureManagement" section in the configuration is missing. /// - public bool RootConfigurationFallbackEnabled { get; init; } + public bool RootConfigurationFallbackEnabled { get; set; } /// /// The logger for the configuration feature definition provider. /// - public ILogger Logger { get; init; } + public ILogger Logger { get; set; } /// /// Disposes the change subscription of the configuration. @@ -129,7 +129,14 @@ public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() // // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned - yield return _definitions.GetOrAdd(featureName, (_) => ReadFeatureDefinition(featureSection)); + FeatureDefinition definition = _definitions.GetOrAdd(featureName, (_) => ReadFeatureDefinition(featureSection)); + + // + // Null cache entry possible if someone accesses non-existent flag directly (IsEnabled) + if (definition != null) + { + yield return definition; + } } } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/ISystemClock.cs b/src/Microsoft.FeatureManagement/FeatureFilters/ISystemClock.cs new file mode 100644 index 00000000..50c6743d --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/ISystemClock.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using System; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// Abstracts the system clock to facilitate testing. + /// .NET8 offers an abstract class TimeProvider. After we stop supporting .NET version less than .NET8, this ISystemClock should retire. + /// + internal interface ISystemClock + { + /// + /// Retrieves the current system time in UTC. + /// + public DateTimeOffset UtcNow { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs index c1d4b9a5..c986e6d9 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; using System.Threading.Tasks; namespace Microsoft.FeatureManagement.FeatureFilters @@ -43,6 +44,11 @@ public object BindParameters(IConfiguration filterParameters) /// True if the feature is enabled, false otherwise. public Task EvaluateAsync(FeatureFilterEvaluationContext context) { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + // // Check if prebound settings available, otherwise bind from parameters. PercentageFilterSettings settings = (PercentageFilterSettings)context.Settings ?? (PercentageFilterSettings)BindParameters(context.Parameters); diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/Recurrence.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/Recurrence.cs new file mode 100644 index 00000000..7a63b6bb --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/Recurrence.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// A recurrence definition describing how time window recurs + /// + public class Recurrence + { + /// + /// The recurrence pattern specifying how often the time window repeats + /// + public RecurrencePattern Pattern { get; set; } + + /// + /// The recurrence range specifying how long the recurrence pattern repeats + /// + public RecurrenceRange Range { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs new file mode 100644 index 00000000..4dba080a --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs @@ -0,0 +1,361 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + static class RecurrenceEvaluator + { + const int DaysPerWeek = 7; + + /// + /// Checks if a provided timestamp is within any recurring time window specified by the Recurrence section in the time window filter settings. + /// If the time window filter has an invalid recurrence setting, an exception will be thrown. + /// A datetime. + /// The settings of time window filter. + /// True if the timestamp is within any recurring time window, false otherwise. + /// + public static bool IsMatch(DateTimeOffset time, TimeWindowFilterSettings settings) + { + Debug.Assert(settings != null); + Debug.Assert(settings.Start != null); + Debug.Assert(settings.Recurrence != null); + Debug.Assert(settings.Recurrence.Pattern != null); + Debug.Assert(settings.Recurrence.Range != null); + + if (time < settings.Start.Value) + { + return false; + } + + if (TryFindPreviousOccurrence(time, settings, out DateTimeOffset previousOccurrence, out int _)) + { + return time < previousOccurrence + (settings.End.Value - settings.Start.Value); + } + + return false; + } + + /// + /// Calculates the start time of the closest active time window. + /// A datetime. + /// The settings of time window filter. + /// The start time of the closest active time window or null if the recurrence range surpasses its end. + /// + public static DateTimeOffset? CalculateClosestStart(DateTimeOffset time, TimeWindowFilterSettings settings) + { + CalculateSurroundingOccurrences(time, settings, out DateTimeOffset? prevOccurrence, out DateTimeOffset? nextOccurrence); + + if (time < settings.Start.Value) + { + return nextOccurrence.Value; + } + + if (prevOccurrence != null) + { + bool isWithinPreviousTimeWindow = + time < prevOccurrence.Value + (settings.End.Value - settings.Start.Value); + + if (isWithinPreviousTimeWindow) + { + return prevOccurrence.Value; + } + + if (nextOccurrence != null) + { + return nextOccurrence.Value; + } + } + + return null; + } + + /// + /// Calculates the closest previous recurrence occurrence (if any) before the given time and the next occurrence (if any) after it. + /// A datetime. + /// The settings of time window filter. + /// The closest previous occurrence. Note that prev occurrence can be null even if the time is past the start date, because the recurrence range may have surpassed its end. + /// The next occurrence. Note that next occurrence can be null even if the prev occurrence is not null, because the recurrence range may have reached its end. + /// + private static void CalculateSurroundingOccurrences(DateTimeOffset time, TimeWindowFilterSettings settings, out DateTimeOffset? prevOccurrence, out DateTimeOffset? nextOccurrence) + { + Debug.Assert(settings != null); + Debug.Assert(settings.Start != null); + Debug.Assert(settings.Recurrence != null); + Debug.Assert(settings.Recurrence.Pattern != null); + Debug.Assert(settings.Recurrence.Range != null); + + prevOccurrence = null; + + nextOccurrence = null; + + if (time < settings.Start.Value) + { + nextOccurrence = settings.Start.Value; + + return; + } + + if (TryFindPreviousOccurrence(time, settings, out DateTimeOffset prev, out int numberOfOccurrences)) + { + prevOccurrence = prev; + + RecurrencePattern pattern = settings.Recurrence.Pattern; + + if (pattern.Type == RecurrencePatternType.Daily) + { + nextOccurrence = prev.AddDays(pattern.Interval); + } + + if (pattern.Type == RecurrencePatternType.Weekly) + { + nextOccurrence = CalculateWeeklyNextOccurrence(prev, settings); + } + + RecurrenceRange range = settings.Recurrence.Range; + + if (range.Type == RecurrenceRangeType.EndDate) + { + if (nextOccurrence > range.EndDate) + { + nextOccurrence = null; + } + } + + if (range.Type == RecurrenceRangeType.Numbered) + { + if (numberOfOccurrences >= range.NumberOfOccurrences) + { + nextOccurrence = null; + } + } + } + } + + /// + /// Finds the closest previous recurrence occurrence before the given time according to the recurrence pattern. + /// The given time should be later than the recurrence start. + /// A return value indicates whether any previous occurrence can be found. + /// A datetime. + /// The settings of time window filter. + /// The closest previous occurrence. + /// The number of occurrences between the time and the recurrence start. + /// True if the closest previous occurrence is within the recurrence range, false otherwise. + /// + private static bool TryFindPreviousOccurrence(DateTimeOffset time, TimeWindowFilterSettings settings, out DateTimeOffset previousOccurrence, out int numberOfOccurrences) + { + Debug.Assert(settings.Start.Value <= time); + + previousOccurrence = DateTimeOffset.MinValue; + + numberOfOccurrences = 0; + + RecurrencePattern pattern = settings.Recurrence.Pattern; + + if (pattern.Type == RecurrencePatternType.Daily) + { + FindDailyPreviousOccurrence(time, settings, out previousOccurrence, out numberOfOccurrences); + } + + if (pattern.Type == RecurrencePatternType.Weekly) + { + FindWeeklyPreviousOccurrence(time, settings, out previousOccurrence, out numberOfOccurrences); + } + + RecurrenceRange range = settings.Recurrence.Range; + + if (range.Type == RecurrenceRangeType.EndDate) + { + return previousOccurrence <= range.EndDate; + } + + if (range.Type == RecurrenceRangeType.Numbered) + { + return numberOfOccurrences <= range.NumberOfOccurrences; + } + + return true; + } + + /// + /// Finds the closest previous recurrence occurrence before the given time according to the "Daily" recurrence pattern. + /// The given time should be later than the recurrence start. + /// A datetime. + /// The settings of time window filter. + /// The closest previous occurrence. + /// The number of occurrences between the time and the recurrence start. + /// + private static void FindDailyPreviousOccurrence(DateTimeOffset time, TimeWindowFilterSettings settings, out DateTimeOffset previousOccurrence, out int numberOfOccurrences) + { + Debug.Assert(settings.Start.Value <= time); + + RecurrencePattern pattern = settings.Recurrence.Pattern; + + DateTimeOffset start = settings.Start.Value; + + int interval = pattern.Interval; + + TimeSpan timeGap = time - start; + + // + // netstandard2.0 does not support '/' operator for TimeSpan. After we stop supporting netstandard2.0, we can remove .TotalSeconds. + int numberOfInterval = (int)Math.Floor(timeGap.TotalSeconds / TimeSpan.FromDays(interval).TotalSeconds); + + previousOccurrence = start.AddDays(numberOfInterval * interval); + + numberOfOccurrences = numberOfInterval + 1; + } + + /// + /// Finds the closest previous recurrence occurrence before the given time according to the "Weekly" recurrence pattern. + /// The given time should be later than the recurrence start. + /// A datetime. + /// The settings of time window filter. + /// The closest previous occurrence. + /// The number of occurrences between the time and the recurrence start. + /// + private static void FindWeeklyPreviousOccurrence(DateTimeOffset time, TimeWindowFilterSettings settings, out DateTimeOffset previousOccurrence, out int numberOfOccurrences) + { + Debug.Assert(settings.Start.Value <= time); + + RecurrencePattern pattern = settings.Recurrence.Pattern; + + DateTimeOffset start = settings.Start.Value; + + int interval = pattern.Interval; + + DateTimeOffset firstDayOfStartWeek = start.AddDays( + -CalculateWeeklyDayOffset(start.DayOfWeek, pattern.FirstDayOfWeek)); + + // + // netstandard2.0 does not support '/' operator for TimeSpan. After we stop supporting netstandard2.0, we can remove .TotalSeconds. + int numberOfInterval = (int)Math.Floor((time - firstDayOfStartWeek).TotalSeconds / TimeSpan.FromDays(interval * DaysPerWeek).TotalSeconds); + + DateTimeOffset firstDayOfMostRecentOccurringWeek = firstDayOfStartWeek.AddDays(numberOfInterval * (interval * DaysPerWeek)); + + List sortedDaysOfWeek = SortDaysOfWeek(pattern.DaysOfWeek, pattern.FirstDayOfWeek); + + // + // Subtract the days before the start in the first week. + numberOfOccurrences = numberOfInterval * sortedDaysOfWeek.Count - sortedDaysOfWeek.IndexOf(start.DayOfWeek); + + // + // The current time is not within the most recent occurring week. + if (time - firstDayOfMostRecentOccurringWeek > TimeSpan.FromDays(DaysPerWeek)) + { + numberOfOccurrences += sortedDaysOfWeek.Count; + + // + // day with max offset in the most recent occurring week + previousOccurrence = firstDayOfMostRecentOccurringWeek.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek.Last(), pattern.FirstDayOfWeek)); + + return; + } + + // + // day with the min offset in the most recent occurring week + DateTimeOffset dayWithMinOffset = firstDayOfMostRecentOccurringWeek.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek.First(), pattern.FirstDayOfWeek)); + + if (dayWithMinOffset < start) + { + numberOfOccurrences = 0; + + dayWithMinOffset = start; + } + + if (time >= dayWithMinOffset) + { + previousOccurrence = dayWithMinOffset; + + numberOfOccurrences++; + + // + // Find the day with the max offset that is less than the current time. + for (int i = sortedDaysOfWeek.IndexOf(dayWithMinOffset.DayOfWeek) + 1; i < sortedDaysOfWeek.Count; i++) + { + DateTimeOffset dayOfWeek = firstDayOfMostRecentOccurringWeek.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek[i], pattern.FirstDayOfWeek)); + + if (time < dayOfWeek) + { + break; + } + + previousOccurrence = dayOfWeek; + + numberOfOccurrences++; + } + } + else + { + // + // the previous occurring week + DateTimeOffset firstDayOfPreviousOccurringWeek = firstDayOfMostRecentOccurringWeek.AddDays(-interval * DaysPerWeek); + + // + // day with max offset in the last occurring week + previousOccurrence = firstDayOfPreviousOccurringWeek.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek.Last(), pattern.FirstDayOfWeek)); + } + } + + /// + /// Finds the next recurrence occurrence after the provided previous occurrence according to the "Weekly" recurrence pattern. + /// The previous occurrence. + /// The settings of time window filter. + /// + private static DateTimeOffset CalculateWeeklyNextOccurrence(DateTimeOffset previousOccurrence, TimeWindowFilterSettings settings) + { + RecurrencePattern pattern = settings.Recurrence.Pattern; + + List sortedDaysOfWeek = SortDaysOfWeek(pattern.DaysOfWeek, pattern.FirstDayOfWeek); + + int i = sortedDaysOfWeek.IndexOf(previousOccurrence.DayOfWeek) + 1; + + if (i < sortedDaysOfWeek.Count()) + { + return previousOccurrence.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek[i], previousOccurrence.DayOfWeek)); + } + + return previousOccurrence.AddDays( + pattern.Interval * DaysPerWeek - CalculateWeeklyDayOffset(previousOccurrence.DayOfWeek, sortedDaysOfWeek.First())); + } + + /// + /// Calculates the offset in days between two given days of the week. + /// A day of week. + /// A day of week. + /// The number of days to be added to day2 to reach day1 + /// + private static int CalculateWeeklyDayOffset(DayOfWeek day1, DayOfWeek day2) + { + return ((int)day1 - (int)day2 + DaysPerWeek) % DaysPerWeek; + } + + + /// + /// Sorts a collection of days of week based on their offsets from a specified first day of week. + /// A collection of days of week. + /// The first day of week. + /// The sorted days of week. + /// + private static List SortDaysOfWeek(IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) + { + List result = daysOfWeek.ToList(); + + result.Sort((x, y) => + CalculateWeeklyDayOffset(x, firstDayOfWeek) + .CompareTo( + CalculateWeeklyDayOffset(y, firstDayOfWeek))); + + return result; + } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePattern.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePattern.cs new file mode 100644 index 00000000..b750a99a --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePattern.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// The recurrence pattern describes the frequency by which the time window repeats. + /// + public class RecurrencePattern + { + /// + /// The recurrence pattern type. + /// + public RecurrencePatternType Type { get; set; } + + /// + /// The number of units between occurrences, where units can be in days or weeks, depending on the pattern type. + /// + public int Interval { get; set; } = 1; + + /// + /// The days of the week on which the time window occurs. This property is only applicable for weekly pattern. + /// + public IEnumerable DaysOfWeek { get; set; } + + /// + /// The first day of the week. This property is only applicable for weekly pattern. + /// + public DayOfWeek FirstDayOfWeek { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePatternType.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePatternType.cs new file mode 100644 index 00000000..89142f37 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePatternType.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// The type of specifying the frequency by which the time window repeats. + /// + public enum RecurrencePatternType + { + /// + /// The pattern where the time window will repeat based on the number of days specified by interval between occurrences. + /// + Daily, + + /// + /// The pattern where the time window will repeat on the same day or days of the week, based on the number of weeks between each set of occurrences. + /// + Weekly + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRange.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRange.cs new file mode 100644 index 00000000..ba852a0e --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRange.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// The recurrence range describes a date range over which the time window repeats. + /// + public class RecurrenceRange + { + /// + /// The recurrence range type. + /// + public RecurrenceRangeType Type { get; set; } + + /// + /// The date to stop applying the recurrence pattern. + /// + public DateTimeOffset EndDate { get; set; } = DateTimeOffset.MaxValue; + + /// + /// The number of times to repeat the time window. + /// + public int NumberOfOccurrences { get; set; } = int.MaxValue; + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRangeType.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRangeType.cs new file mode 100644 index 00000000..942aa48b --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRangeType.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// The type of specifying the date range over which the time window repeats. + /// + public enum RecurrenceRangeType + { + /// + /// The time window repeats on all the days that fit the corresponding . + /// + NoEnd, + + /// + /// The time window repeats on all the days that fit the corresponding before or on the end date specified in EndDate of . + /// + EndDate, + + /// + /// The time window repeats for the number specified in the NumberOfOccurrences of that fit based on the . + /// + Numbered + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs new file mode 100644 index 00000000..230ae674 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs @@ -0,0 +1,426 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + static class RecurrenceValidator + { + const int DaysPerWeek = 7; + + // + // Error Message + const string ValueOutOfRange = "The value is out of the accepted range."; + const string UnrecognizableValue = "The value is unrecognizable."; + const string RequiredParameter = "Value cannot be null or empty."; + const string StartNotMatched = "Start date is not a valid first occurrence."; + const string TimeWindowDurationOutOfRange = "Time window duration cannot be longer than how frequently it occurs or be longer than 10 years."; + + /// + /// Performs validation of time window settings. + /// The settings of time window filter. + /// 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(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + if (settings.Recurrence != null) + { + return TryValidateRecurrenceRequiredParameter(settings, out paramName, out reason) && + TryValidateRecurrencePattern(settings, out paramName, out reason) && + TryValidateRecurrenceRange(settings, out paramName, out reason); + } + + paramName = null; + + reason = null; + + return true; + } + + private static bool TryValidateRecurrenceRequiredParameter(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + Debug.Assert(settings != null); + Debug.Assert(settings.Recurrence != null); + + if (settings.Start == null) + { + paramName = nameof(settings.Start); + + reason = RequiredParameter; + + return false; + } + + if (settings.End == null) + { + paramName = nameof(settings.End); + + reason = RequiredParameter; + + return false; + } + + Recurrence recurrence = settings.Recurrence; + + if (recurrence.Pattern == null) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(recurrence.Pattern)}"; + + reason = RequiredParameter; + + return false; + } + + if (recurrence.Range == null) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(recurrence.Range)}"; + + reason = RequiredParameter; + + return false; + } + + if (settings.End.Value <= settings.Start.Value) + { + paramName = nameof(settings.End); + + reason = ValueOutOfRange; + + return false; + } + + if (settings.End.Value - settings.Start.Value >= TimeSpan.FromDays(3650)) + { + paramName = nameof(settings.End); + + reason = TimeWindowDurationOutOfRange; + + return false; + } + + paramName = null; + + reason = null; + + return true; + } + + private static bool TryValidateRecurrencePattern(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + Debug.Assert(settings != null); + Debug.Assert(settings.Start != null); + Debug.Assert(settings.End != null); + Debug.Assert(settings.Recurrence != null); + Debug.Assert(settings.Recurrence.Pattern != null); + + if (!TryValidateInterval(settings, out paramName, out reason)) + { + return false; + } + + switch (settings.Recurrence.Pattern.Type) + { + case RecurrencePatternType.Daily: + return TryValidateDailyRecurrencePattern(settings, out paramName, out reason); + + case RecurrencePatternType.Weekly: + return TryValidateWeeklyRecurrencePattern(settings, out paramName, out reason); + + default: + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Pattern)}.{nameof(settings.Recurrence.Pattern.Type)}"; + + reason = UnrecognizableValue; + + return false; + } + } + + private static bool TryValidateDailyRecurrencePattern(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + Debug.Assert(settings.Recurrence.Pattern.Interval > 0); + + // + // No required parameter for "Daily" pattern + // "Start" is always a valid first occurrence for "Daily" pattern + + TimeSpan intervalDuration = TimeSpan.FromDays(settings.Recurrence.Pattern.Interval); + + TimeSpan timeWindowDuration = settings.End.Value - settings.Start.Value; + + // + // Time window duration must be shorter than how frequently it occurs + if (timeWindowDuration > intervalDuration) + { + paramName = $"{nameof(settings.End)}"; + + reason = TimeWindowDurationOutOfRange; + + return false; + } + + paramName = null; + + reason = null; + + return true; + } + + private static bool TryValidateWeeklyRecurrencePattern(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + RecurrencePattern pattern = settings.Recurrence.Pattern; + + Debug.Assert(pattern.Interval > 0); + + // + // Required parameters + if (!TryValidateDaysOfWeek(settings, out paramName, out reason)) + { + return false; + } + + TimeSpan intervalDuration = TimeSpan.FromDays(pattern.Interval * DaysPerWeek); + + TimeSpan timeWindowDuration = settings.End.Value - settings.Start.Value; + + // + // Time window duration must be shorter than how frequently it occurs + if (timeWindowDuration > intervalDuration || + !IsDurationCompliantWithDaysOfWeek(timeWindowDuration, pattern.Interval, pattern.DaysOfWeek, pattern.FirstDayOfWeek)) + { + paramName = $"{nameof(settings.End)}"; + + reason = TimeWindowDurationOutOfRange; + + return false; + } + + // + // Check whether "Start" is a valid first occurrence + DateTimeOffset start = settings.Start.Value; + + if (!pattern.DaysOfWeek.Any(day => + day == start.DayOfWeek)) + { + paramName = nameof(settings.Start); + + reason = StartNotMatched; + + return false; + } + + return true; + } + + private static bool TryValidateRecurrenceRange(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + Debug.Assert(settings != null); + Debug.Assert(settings.Start != null); + Debug.Assert(settings.Recurrence != null); + Debug.Assert(settings.Recurrence.Range != null); + + switch (settings.Recurrence.Range.Type) + { + case RecurrenceRangeType.NoEnd: + paramName = null; + + reason = null; + + return true; + + case RecurrenceRangeType.EndDate: + return TryValidateEndDate(settings, out paramName, out reason); + + case RecurrenceRangeType.Numbered: + return TryValidateNumberOfOccurrences(settings, out paramName, out reason); + + default: + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Range)}.{nameof(settings.Recurrence.Range.Type)}"; + + reason = UnrecognizableValue; + + return false; + } + } + + private static bool TryValidateInterval(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Pattern)}.{nameof(settings.Recurrence.Pattern.Interval)}"; + + if (settings.Recurrence.Pattern.Interval <= 0) + { + reason = ValueOutOfRange; + + return false; + } + + reason = null; + + return true; + } + + private static bool TryValidateDaysOfWeek(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Pattern)}.{nameof(settings.Recurrence.Pattern.DaysOfWeek)}"; + + if (settings.Recurrence.Pattern.DaysOfWeek == null || !settings.Recurrence.Pattern.DaysOfWeek.Any()) + { + reason = RequiredParameter; + + return false; + } + + reason = null; + + return true; + } + + private static bool TryValidateEndDate(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Range)}.{nameof(settings.Recurrence.Range.EndDate)}"; + + if (settings.Start == null) + { + paramName = nameof(settings.Start); + + reason = RequiredParameter; + + return false; + } + + DateTimeOffset start = settings.Start.Value; + + DateTimeOffset endDate = settings.Recurrence.Range.EndDate; + + if (endDate < start) + { + reason = ValueOutOfRange; + + return false; + } + + reason = null; + + return true; + } + + private static bool TryValidateNumberOfOccurrences(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Range)}.{nameof(settings.Recurrence.Range.NumberOfOccurrences)}"; + + if (settings.Recurrence.Range.NumberOfOccurrences < 1) + { + reason = ValueOutOfRange; + + return false; + } + + reason = null; + + return true; + } + + /// + /// Checks whether the duration is shorter than the minimum gap between recurrence of days of week. + /// + /// The time span of the duration. + /// The recurrence interval. + /// The days of the week when the recurrence will occur. + /// The first day of the week. + /// True if the duration is compliant with days of week, false otherwise. + private static bool IsDurationCompliantWithDaysOfWeek(TimeSpan duration, int interval, IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) + { + Debug.Assert(interval > 0); + + if (daysOfWeek.Count() == 1) + { + return true; + } + + DateTime firstDayOfThisWeek = DateTime.Today.AddDays( + DaysPerWeek - CalculateWeeklyDayOffset(DateTime.Today.DayOfWeek, firstDayOfWeek)); + + List sortedDaysOfWeek = SortDaysOfWeek(daysOfWeek, firstDayOfWeek); + + DateTime prev = DateTime.MinValue; + + TimeSpan minGap = TimeSpan.FromDays(DaysPerWeek); + + foreach (DayOfWeek dayOfWeek in sortedDaysOfWeek) + { + DateTime date = firstDayOfThisWeek.AddDays( + CalculateWeeklyDayOffset(dayOfWeek, firstDayOfWeek)); + + if (prev != DateTime.MinValue) + { + TimeSpan gap = date - prev; + + if (gap < minGap) + { + minGap = gap; + } + } + + prev = date; + } + + // + // It may across weeks. Check the next week if the interval is one week. + if (interval == 1) + { + DateTime firstDayOfNextWeek = firstDayOfThisWeek.AddDays(DaysPerWeek); + + DateTime firstOccurrenceInNextWeek = firstDayOfNextWeek.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek.First(), firstDayOfWeek)); + + TimeSpan gap = firstOccurrenceInNextWeek - prev; + + if (gap < minGap) + { + minGap = gap; + } + } + + return minGap >= duration; + } + + /// + /// Calculates the offset in days between two given days of the week. + /// A day of week. + /// A day of week. + /// The number of days to be added to day2 to reach day1 + /// + private static int CalculateWeeklyDayOffset(DayOfWeek day1, DayOfWeek day2) + { + return ((int)day1 - (int)day2 + DaysPerWeek) % DaysPerWeek; + } + + /// + /// Sorts a collection of days of week based on their offsets from a specified first day of week. + /// A collection of days of week. + /// The first day of week. + /// The sorted days of week. + /// + private static List SortDaysOfWeek(IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) + { + List result = daysOfWeek.ToList(); + + result.Sort((x, y) => + CalculateWeeklyDayOffset(x, firstDayOfWeek) + .CompareTo( + CalculateWeeklyDayOffset(y, firstDayOfWeek))); + + return result; + } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs index 079b8172..fb1f6f01 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; @@ -10,10 +11,14 @@ namespace Microsoft.FeatureManagement.FeatureFilters { /// /// A feature filter that can be used to activate a feature based on a time window. + /// The time window can be configured to recur periodically. /// [FilterAlias(Alias)] public class TimeWindowFilter : IFeatureFilter, IFilterParametersBinder { + private readonly TimeSpan CacheSlidingExpiration = TimeSpan.FromMinutes(5); + private readonly TimeSpan CacheAbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); + private const string Alias = "Microsoft.TimeWindow"; private readonly ILogger _logger; @@ -26,6 +31,16 @@ public TimeWindowFilter(ILoggerFactory loggerFactory = null) _logger = loggerFactory?.CreateLogger(); } + /// + /// The application memory cache to store the start time of the closest active time window. By caching this time, the time window can minimize redundant computations when evaluating recurrence. + /// + public IMemoryCache Cache { get; set; } + + /// + /// This property allows the time window filter in our test suite to use simulated time. + /// + internal ISystemClock SystemClock { get; set; } + /// /// Binds configuration representing filter parameters to . /// @@ -33,21 +48,33 @@ public TimeWindowFilter(ILoggerFactory loggerFactory = null) /// that can later be used in feature evaluation. public object BindParameters(IConfiguration filterParameters) { - return filterParameters.Get() ?? new TimeWindowFilterSettings(); + var settings = filterParameters.Get() ?? new TimeWindowFilterSettings(); + + if (!RecurrenceValidator.TryValidateSettings(settings, out string paramName, out string reason)) + { + throw new ArgumentException(reason, paramName); + } + + return settings; } /// - /// Evaluates whether a feature is enabled based on a configurable time window. + /// Evaluates whether a feature is enabled based on the specified in the configuration. /// /// The feature evaluation context. /// True if the feature is enabled, false otherwise. public Task EvaluateAsync(FeatureFilterEvaluationContext context) { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + // // Check if prebound settings available, otherwise bind from parameters. TimeWindowFilterSettings settings = (TimeWindowFilterSettings)context.Settings ?? (TimeWindowFilterSettings)BindParameters(context.Parameters); - DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset now = SystemClock?.UtcNow ?? DateTimeOffset.UtcNow; if (!settings.Start.HasValue && !settings.End.HasValue) { @@ -56,7 +83,67 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) return Task.FromResult(false); } - return Task.FromResult((!settings.Start.HasValue || now >= settings.Start.Value) && (!settings.End.HasValue || now < settings.End.Value)); + // + // Hit the first occurrence of the time window + if ((!settings.Start.HasValue || now >= settings.Start.Value) && (!settings.End.HasValue || now < settings.End.Value)) + { + return Task.FromResult(true); + } + + if (settings.Recurrence != null) + { + // + // The reference of the object will be used for cache key. + // If there is no pre-bounded settings attached to the context, there will be no cached filter settings and each call will have a unique settings object. + // In this case, the cache for recurrence settings won't work. + if (Cache == null || context.Settings == null) + { + return Task.FromResult(RecurrenceEvaluator.IsMatch(now, settings)); + } + + // + // The start time of the closest active time window. It could be null if the recurrence range surpasses its end. + DateTimeOffset? closestStart; + + TimeSpan activeDuration = settings.End.Value - settings.Start.Value; + + // + // Recalculate the closest start if not yet calculated, + // Or if we have passed the cached time window. + if (!Cache.TryGetValue(settings, out closestStart) || + (closestStart.HasValue && now >= closestStart.Value + activeDuration)) + { + closestStart = ReloadClosestStart(settings); + } + + if (!closestStart.HasValue || now < closestStart.Value) + { + return Task.FromResult(false); + } + + return Task.FromResult(now < closestStart.Value + activeDuration); + } + + return Task.FromResult(false); + } + + private DateTimeOffset? ReloadClosestStart(TimeWindowFilterSettings settings) + { + DateTimeOffset now = SystemClock?.UtcNow ?? DateTimeOffset.UtcNow; + + DateTimeOffset? closestStart = RecurrenceEvaluator.CalculateClosestStart(now, settings); + + Cache.Set( + settings, + closestStart, + new MemoryCacheEntryOptions + { + SlidingExpiration = CacheSlidingExpiration, + AbsoluteExpirationRelativeToNow = CacheAbsoluteExpirationRelativeToNow, + Size = 1 + }); + + return closestStart; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs index 41f87cf3..6a0bb0d4 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs @@ -14,12 +14,18 @@ public class TimeWindowFilterSettings /// An optional start time used to determine when a feature 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" + public DateTimeOffset? Start { get; set; } /// /// An optional end time used to determine when a feature 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" + public DateTimeOffset? End { get; set; } + + /// + /// Add-on recurrence rule allows the time window defined by Start and End to recur. + /// The rule specifies both how often the time window repeats and for how long. + /// + public Recurrence Recurrence { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs index 031d6f42..2b2af56c 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement.FeatureFilters; namespace Microsoft.FeatureManagement { @@ -26,6 +27,13 @@ public IFeatureManagementBuilder AddFeatureFilter() where T : IFeatureFilterM Type implementationType = typeof(T); + // + // TimeWindowFilter will only be added through another overload of AddFeatureFilter + if (implementationType == typeof(TimeWindowFilter)) + { + return this; + } + IEnumerable featureFilterImplementations = implementationType.GetInterfaces() .Where(i => i == typeof(IFeatureFilter) || (i.IsGenericType && i.GetGenericTypeDefinition().IsAssignableFrom(typeof(IContextualFeatureFilter<>)))); @@ -52,6 +60,38 @@ public IFeatureManagementBuilder AddFeatureFilter() where T : IFeatureFilterM return this; } + public IFeatureManagementBuilder AddFeatureFilter(Func implementationFactory) where T : IFeatureFilterMetadata + { + Type serviceType = typeof(IFeatureFilterMetadata); + + Type implementationType = typeof(T); + + IEnumerable featureFilterImplementations = implementationType.GetInterfaces() + .Where(i => i == typeof(IFeatureFilter) || + (i.IsGenericType && i.GetGenericTypeDefinition().IsAssignableFrom(typeof(IContextualFeatureFilter<>)))); + + if (featureFilterImplementations.Count() > 1) + { + throw new ArgumentException($"A single feature filter cannot implement more than one feature filter interface.", nameof(T)); + } + + if (!Services.Any(descriptor => descriptor.ServiceType == serviceType && descriptor.ImplementationType == implementationType)) + { + // + // Register the feature filter with the same lifetime as the feature manager + if (Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped)) + { + Services.AddScoped(serviceType, implementationFactory); + } + else + { + Services.AddSingleton(serviceType, implementationFactory); + } + } + + return this; + } + public IFeatureManagementBuilder AddSessionManager() where T : ISessionManager { // diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index da0d70ce..f8cb0800 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -27,12 +27,14 @@ public sealed class FeatureManager : IFeatureManager, IVariantFeatureManager private readonly TimeSpan ParametersCacheAbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); private readonly IFeatureDefinitionProvider _featureDefinitionProvider; - private readonly FeatureManagementOptions _options; private readonly ConcurrentDictionary _filterMetadataCache; private readonly ConcurrentDictionary _contextualFeatureFilterCache; - private readonly IEnumerable _featureFilters; - private readonly IEnumerable _sessionManagers; - private readonly TargetingEvaluationOptions _assignerOptions; + private readonly FeatureManagementOptions _options; + + private IEnumerable _featureFilters; + private IEnumerable _sessionManagers; + private TargetingEvaluationOptions _assignerOptions; + /// /// The activity source for feature management. @@ -73,7 +75,7 @@ public IEnumerable FeatureFilters { get => _featureFilters; - init + set { _featureFilters = value ?? throw new ArgumentNullException(nameof(value)); } @@ -87,7 +89,7 @@ public IEnumerable SessionManagers { get => _sessionManagers; - init + set { _sessionManagers = value ?? throw new ArgumentNullException(nameof(value)); } @@ -96,22 +98,22 @@ public IEnumerable SessionManagers /// /// The application memory cache to store feature filter settings. /// - public IMemoryCache Cache { get; init; } + public IMemoryCache Cache { get; set; } /// /// The logger for the feature manager. /// - public ILogger Logger { get; init; } + public ILogger Logger { get; set; } /// /// The configuration reference for feature variants. /// - public IConfiguration Configuration { get; init; } + public IConfiguration Configuration { get; set; } /// /// The targeting context accessor for feature variant allocation. /// - public ITargetingContextAccessor TargetingContextAccessor { get; init; } + public ITargetingContextAccessor TargetingContextAccessor { get; set; } /// /// Options controlling the targeting behavior for feature variant allocation. @@ -121,7 +123,7 @@ public TargetingEvaluationOptions AssignerOptions { get => _assignerOptions; - init + set { _assignerOptions = value ?? throw new ArgumentNullException(nameof(value)); } diff --git a/src/Microsoft.FeatureManagement/IsExternalInit.cs b/src/Microsoft.FeatureManagement/IsExternalInit.cs deleted file mode 100644 index 2798741c..00000000 --- a/src/Microsoft.FeatureManagement/IsExternalInit.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// - -// The init accessor for properties is supported in C# 9.0 and later. -// This class is used to compile .NET frameworks that don't support C# 9.0 or later while still using the init accessor for a property. -// The code referenced for this file can be found here: https://github.com/dotnet/roslyn/issues/45510#issuecomment-725091019 - -#if NETSTANDARD2_0 || NETSTANDARD2_1 - -using System.ComponentModel; - -namespace System.Runtime.CompilerServices -{ - [EditorBrowsable(EditorBrowsableState.Never)] - internal static class IsExternalInit - { - } -} - -#endif diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj index 2828ce79..5c0dce5d 100644 --- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj +++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj @@ -16,15 +16,19 @@ true false ..\..\build\Microsoft.FeatureManagement.snk - 10.0 + + 8.0 Microsoft.FeatureManagement provides a way to develop and expose application functionality based on features. Many applications have special requirements when a new feature is developed such as when the feature should be enabled and under what conditions. This library provides a way to define these relationships, and also integrates into common .NET code patterns to make exposing these features possible. Microsoft Microsoft - https://github.com/Azure/AppConfiguration - https://licenses.nuget.org/MIT + https://github.com/microsoft/FeatureManagement-Dotnet + https://github.com/microsoft/FeatureManagement-Dotnet.git + git + MIT Release notes can be found at https://aka.ms/MicrosoftFeatureManagementReleaseNotes Microsoft FeatureManagement FeatureFlags AzureAppConfiguration https://aka.ms/AzureAppConfigurationPackageIcon diff --git a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs index f6412716..346a0016 100644 --- a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs +++ b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs @@ -71,7 +71,11 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec // Add built-in feature filters builder.AddFeatureFilter(); - builder.AddFeatureFilter(); + builder.AddFeatureFilter(sp => + new TimeWindowFilter() + { + Cache = sp.GetRequiredService() + }); builder.AddFeatureFilter(); @@ -154,7 +158,11 @@ public static IFeatureManagementBuilder AddScopedFeatureManagement(this IService // Add built-in feature filters builder.AddFeatureFilter(); - builder.AddFeatureFilter(); + builder.AddFeatureFilter(sp => + new TimeWindowFilter() + { + Cache = sp.GetRequiredService() + }); builder.AddFeatureFilter(); diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 478501d6..f1a2ebda 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -826,6 +826,9 @@ public async Task TimeWindow() const string feature2 = "feature2"; const string feature3 = "feature3"; const string feature4 = "feature4"; + const string feature5 = "feature5"; + const string feature6 = "feature6"; + const string feature7 = "feature7"; Environment.SetEnvironmentVariable($"feature_management:feature_flags:0:id", feature1); Environment.SetEnvironmentVariable($"feature_management:feature_flags:0:enabled", "true"); @@ -847,6 +850,37 @@ public async Task TimeWindow() Environment.SetEnvironmentVariable($"feature_management:feature_flags:3:conditions:client_filters:0:name", "TimeWindow"); Environment.SetEnvironmentVariable($"feature_management:feature_flags:3:conditions:client_filters:0:parameters:Start", DateTimeOffset.UtcNow.AddDays(1).ToString("r")); + Environment.SetEnvironmentVariable("feature_management:feature_flags:4:id", feature5); + Environment.SetEnvironmentVariable("feature_management:feature_flags:4:enabled", "true"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:4:conditions:client_filters:0:name", "TimeWindow"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:4:conditions:client_filters:0:parameters:Start", DateTimeOffset.UtcNow.AddDays(-2).ToString("r")); + Environment.SetEnvironmentVariable("feature_management:feature_flags:4:conditions:client_filters:0:parameters:End", DateTimeOffset.UtcNow.AddDays(-1).ToString("r")); + Environment.SetEnvironmentVariable("feature_management:feature_flags:4:conditions:client_filters:0:parameters:Recurrence:Pattern:Type", "Daily"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:4:conditions:client_filters:0:parameters:Recurrence:Range:Type", "NoEnd"); + + Environment.SetEnvironmentVariable("feature_management:feature_flags:5:id", feature6); + Environment.SetEnvironmentVariable("feature_management:feature_flags:5:enabled", "true"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:5:conditions:client_filters:0:name", "TimeWindow"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:5:conditions:client_filters:0:parameters:Start", DateTimeOffset.UtcNow.AddDays(-2).ToString("r")); + Environment.SetEnvironmentVariable("feature_management:feature_flags:5:conditions:client_filters:0:parameters:End", DateTimeOffset.UtcNow.AddDays(-1).ToString("r")); + Environment.SetEnvironmentVariable("feature_management:feature_flags:5:conditions:client_filters:0:parameters:Recurrence:Pattern:Type", "Daily"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:5:conditions:client_filters:0:parameters:Recurrence:Pattern:Interval", "3"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:5:conditions:client_filters:0:parameters:Recurrence:Range:Type", "NoEnd"); + + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:id", feature7); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:enabled", "true"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:name", "TimeWindow"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Start", DateTimeOffset.UtcNow.AddDays(-2).ToString("r")); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:End", DateTimeOffset.UtcNow.AddDays(-1).ToString("r")); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:Type", "Weekly"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Range:Type", "NoEnd"); + + foreach (DayOfWeek day in Enum.GetValues(typeof(DayOfWeek))) + { + int dayIndex = (int)day; + Environment.SetEnvironmentVariable($"feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:{dayIndex}", day.ToString()); + } + IConfiguration config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); var serviceCollection = new ServiceCollection(); @@ -862,6 +896,13 @@ public async Task TimeWindow() Assert.False(await featureManager.IsEnabledAsync(feature2)); Assert.True(await featureManager.IsEnabledAsync(feature3)); Assert.False(await featureManager.IsEnabledAsync(feature4)); + Assert.True(await featureManager.IsEnabledAsync(feature5)); + Assert.False(await featureManager.IsEnabledAsync(feature6)); + + for (int i = 0; i < 10; i++) + { + Assert.True(await featureManager.IsEnabledAsync(feature7)); + } } [Fact] @@ -874,7 +915,7 @@ public async Task Percentage() Environment.SetEnvironmentVariable($"feature_management:feature_flags:0:conditions:client_filters:0:name", "Percentage"); Environment.SetEnvironmentVariable($"feature_management:feature_flags:0:conditions:client_filters:0:parameters:Value", "50"); - IConfiguration config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); + IConfiguration config = new ConfigurationBuilder().AddEnvironmentVariables().AddJsonFile("appsettings.json").Build(); var serviceCollection = new ServiceCollection(); @@ -1013,6 +1054,269 @@ public async Task TargetingAccessor() })); } + [Fact] + public async Task UsesContext() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var serviceCollection = new ServiceCollection(); + + serviceCollection.AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter(); + + ServiceProvider provider = serviceCollection.BuildServiceProvider(); + + ContextualTestFilter contextualTestFeatureFilter = (ContextualTestFilter)provider.GetRequiredService>().First(f => f is ContextualTestFilter); + + contextualTestFeatureFilter.ContextualCallback = (ctx, accountContext) => + { + var allowedAccounts = new List(); + + ctx.Parameters.Bind("AllowedAccounts", allowedAccounts); + + return allowedAccounts.Contains(accountContext.AccountId); + }; + + IFeatureManager featureManager = provider.GetRequiredService(); + + AppContext context = new AppContext(); + + context.AccountId = "NotEnabledAccount"; + + Assert.False(await featureManager.IsEnabledAsync(Features.ContextualFeature, context)); + + context.AccountId = "abc"; + + Assert.True(await featureManager.IsEnabledAsync(Features.ContextualFeature, context)); + } + + [Fact] + public void LimitsFeatureFilterImplementations() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var serviceCollection = new ServiceCollection(); + + Assert.Throws(() => + { + new ServiceCollection().AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter(); + }); + + Assert.Throws(() => + { + new ServiceCollection().AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter(); + }); + } + + [Fact] + public async Task ListsFeatures() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var serviceCollection = new ServiceCollection(); + + serviceCollection.AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter(); + + using (ServiceProvider provider = serviceCollection.BuildServiceProvider()) + { + IFeatureManager featureManager = provider.GetRequiredService(); + + bool hasItems = false; + + await foreach (string feature in featureManager.GetFeatureNamesAsync()) + { + hasItems = true; + + break; + } + + Assert.True(hasItems); + } + } + + [Fact] + public async Task ThrowsExceptionForMissingFeatureFilter() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .AddSingleton(config) + .AddFeatureManagement(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + FeatureManagementException e = await Assert.ThrowsAsync(async () => await featureManager.IsEnabledAsync(Features.ConditionalFeature)); + + Assert.Equal(FeatureManagementError.MissingFeatureFilter, e.Error); + } + + [Fact] + public async Task SwallowsExceptionForMissingFeatureFilter() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .Configure(options => + { + options.IgnoreMissingFeatureFilters = true; + }); + + services + .AddSingleton(config) + .AddFeatureManagement(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + var isEnabled = await featureManager.IsEnabledAsync(Features.ConditionalFeature); + + Assert.False(isEnabled); + } + + [Fact] + public async Task ThrowsForMissingFeatures() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .Configure(options => + { + options.IgnoreMissingFeatures = false; + }); + + services + .AddSingleton(config) + .AddFeatureManagement(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + FeatureManagementException fme = await Assert.ThrowsAsync(() => + featureManager.IsEnabledAsync("NonExistentFeature")); + } + + [Fact] + public async Task CustomFeatureDefinitionProvider() + { + FeatureDefinition testFeature = new FeatureDefinition + { + Name = Features.ConditionalFeature, + EnabledFor = new List() + { + new FeatureFilterConfiguration + { + Name = "Test", + Parameters = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary() + { + { "P1", "V1" }, + }).Build() + } + } + }; + + var services = new ServiceCollection(); + + services.AddSingleton(new InMemoryFeatureDefinitionProvider(new FeatureDefinition[] { testFeature })) + .AddFeatureManagement() + .AddFeatureFilter(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + 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(Features.ConditionalFeature, evaluationContext.FeatureName); + + return Task.FromResult(true); + }; + + await featureManager.IsEnabledAsync(Features.ConditionalFeature); + + Assert.True(called); + } + + [Fact] + public async Task ThreadsafeSnapshot() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + IEnumerable featureFilters = serviceProvider.GetRequiredService>(); + + // + // Sync filter + TestFilter testFeatureFilter = (TestFilter)featureFilters.First(f => f is TestFilter); + + bool called = false; + + testFeatureFilter.Callback = async (evaluationContext) => + { + called = true; + + await Task.Delay(10); + + return new Random().Next(0, 100) > 50; + }; + + var tasks = new List>(); + + for (int i = 0; i < 1000; i++) + { + tasks.Add(featureManager.IsEnabledAsync(Features.ConditionalFeature)); + } + + Assert.True(called); + + await Task.WhenAll(tasks); + + bool result = await tasks.First(); + + foreach (Task t in tasks) + { + Assert.Equal(result, await t); + } + } + [Fact] public async Task TargetingExclusion() { diff --git a/tests/Tests.FeatureManagement/OnDemandClock.cs b/tests/Tests.FeatureManagement/OnDemandClock.cs new file mode 100644 index 00000000..c639a3e3 --- /dev/null +++ b/tests/Tests.FeatureManagement/OnDemandClock.cs @@ -0,0 +1,10 @@ +using Microsoft.FeatureManagement.FeatureFilters; +using System; + +namespace Tests.FeatureManagement +{ + class OnDemandClock : ISystemClock + { + public DateTimeOffset UtcNow { get; set; } + } +} diff --git a/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs b/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs new file mode 100644 index 00000000..50c6e449 --- /dev/null +++ b/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs @@ -0,0 +1,1740 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Caching.Memory; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Tests.FeatureManagement +{ + class ErrorMessage + { + public const string ValueOutOfRange = "The value is out of the accepted range."; + public const string UnrecognizableValue = "The value is unrecognizable."; + public const string RequiredParameter = "Value cannot be null or empty."; + public const string StartNotMatched = "Start date is not a valid first occurrence."; + public const string TimeWindowDurationOutOfRange = "Time window duration cannot be longer than how frequently it occurs or be longer than 10 years."; + } + + class ParamName + { + public const string Start = "Start"; + public const string End = "End"; + + public const string Pattern = "Recurrence.Pattern"; + public const string PatternType = "Recurrence.Pattern.Type"; + public const string Interval = "Recurrence.Pattern.Interval"; + public const string DaysOfWeek = "Recurrence.Pattern.DaysOfWeek"; + public const string Month = "Recurrence.Pattern.Month"; + public const string DayOfMonth = "Recurrence.Pattern.DayOfMonth"; + + public const string Range = "Recurrence.Range"; + public const string RangeType = "Recurrence.Range.Type"; + public const string NumberOfOccurrences = "Recurrence.Range.NumberOfOccurrences"; + public const string RecurrenceTimeZone = "Recurrence.Range.RecurrenceTimeZone"; + public const string EndDate = "Recurrence.Range.EndDate"; + } + + public class RecurrenceValidatorTest + { + private static void ConsumeValidationTestData(List> testData) + { + foreach ((TimeWindowFilterSettings settings, string paramNameRef, string errorMessageRef) in testData) + { + RecurrenceValidator.TryValidateSettings(settings, out string paramName, out string errorMessage); + + Assert.Equal(paramNameRef, paramName); + Assert.Equal(errorMessageRef, errorMessage); + } + } + + [Fact] + public void GeneralRequiredParameterTest() + { + var testData = new List>() + { + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-25T12:00:00+08:00"), + End = null, + Recurrence = new Recurrence() + }, + ParamName.End, + ErrorMessage.RequiredParameter ), + + ( new TimeWindowFilterSettings() + { + Start = null, + End = DateTimeOffset.Parse("2023-9-25T12:00:00+08:00"), + Recurrence = new Recurrence() + }, + ParamName.Start, + ErrorMessage.RequiredParameter ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-25T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-25T02:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = null, + Range = new RecurrenceRange() + } + }, + ParamName.Pattern, + ErrorMessage.RequiredParameter ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-25T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-25T02:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern(), + Range = null + } + }, + ParamName.Range, + ErrorMessage.RequiredParameter ) + }; + + ConsumeValidationTestData(testData); + } + + [Fact] + public void InvalidValueTest() + { + var testData = new List>() + { + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Interval = 0 // Interval should be larger than 0. + }, + Range = new RecurrenceRange() + } + }, + ParamName.Interval, + ErrorMessage.ValueOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern(), + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 0 // NumberOfOccurrences should be larger than 0. + } + } + }, + ParamName.NumberOfOccurrences, + ErrorMessage.ValueOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern(), + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2023-8-31T23:59:59+08:00") // EndDate is earlier than the Start. + } + } + }, + ParamName.EndDate, + ErrorMessage.ValueOutOfRange ) + }; + + ConsumeValidationTestData(testData); + } + + [Fact] + public void InvalidTimeWindowTest() + { + var testData = new List>() + { + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-25T12:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-25T12:00:00+08:00"), // End equals to Start. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern(), + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.ValueOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-25T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-27T00:00:01+08:00"), // The duration of the time window is longer than how frequently it recurs. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.TimeWindowDurationOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-8T00:00:01+08:00"), // The duration of the time window is longer than how frequently it recurs. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + DaysOfWeek = new List(){ DayOfWeek.Friday } // 2023.9.1 is Friday. + }, + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.TimeWindowDurationOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-5T00:00:01+08:00"), // The duration of the time window is longer than how frequently it recurs. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + // FirstDayOfWeek is Sunday by default + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.TimeWindowDurationOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-2T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-5T00:00:01+08:00"), // The duration of the time window is longer than how frequently it recurs. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + // FirstDayOfWeek is Sunday by default + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Saturday } // The time window duration should be shorter than 2 days because the gap between Saturday in the previous week and Monday in this week is 2 days. + }, + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.TimeWindowDurationOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-16T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-1-19T00:00:01+08:00"), // The duration of the time window is longer than how frequently it recurs. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + DaysOfWeek = new List(){ DayOfWeek.Tuesday, DayOfWeek.Saturday } // The time window duration should be shorter than 3 days because the gap between Saturday in the previous week and Tuesday in this week is 3 days. + }, + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.TimeWindowDurationOutOfRange ) + }; + + ConsumeValidationTestData(testData); + } + + [Fact] + public void InvalidTimeWindowAcrossWeeksTest() + { + var settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-16T00:00:00+08:00"), // Tuesday + End = DateTimeOffset.Parse("2024-1-19T00:00:00+08:00"), // Time window duration is 3 days. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + DaysOfWeek = new List() { DayOfWeek.Tuesday, DayOfWeek.Saturday } // The time window duration should be shorter than 3 days because the gap between Saturday in the previous week and Tuesday in this week is 3 days. + }, + Range = new RecurrenceRange() + } + }; + + // + // The settings is valid. No exception should be thrown. + RecurrenceEvaluator.IsMatch(DateTimeOffset.UtcNow, settings); + + settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-15T00:00:00+08:00"), // Monday + End = DateTimeOffset.Parse("2024-1-19T00:00:00+08:00"), // Time window duration is 4 days. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, // The interval is larger than one week, there is no across-week issue. + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List() { DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }; + + // + // The settings is valid. No exception should be thrown. + RecurrenceEvaluator.IsMatch(DateTimeOffset.UtcNow, settings); + + settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-15T00:00:00+08:00"), // Monday + End = DateTimeOffset.Parse("2024-1-19T00:00:00+08:00"), // Time window duration is 4 days. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List() { DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }; + + Assert.False(RecurrenceValidator.TryValidateSettings(settings, out string paramName, out string errorMessage)); + Assert.Equal(ParamName.End, paramName); + Assert.Equal(ErrorMessage.TimeWindowDurationOutOfRange, errorMessage); + } + + [Fact] + public void WeeklyPatternRequiredParameterTest() + { + var testData = new List>() + { + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = Enumerable.Empty() + }, + Range = new RecurrenceRange() + } + }, + ParamName.DaysOfWeek, + ErrorMessage.RequiredParameter ) + }; + + ConsumeValidationTestData(testData); + } + + [Fact] + public void WeeklyPatternStartNotMatchedTest() + { + var testData = new List>() + { + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // 2023-9-1 is Friday. Start date is not a valid first occurrence. + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List{ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + ParamName.Start, + ErrorMessage.StartNotMatched ) + }; + + ConsumeValidationTestData(testData); + } + } + + public class RecurrenceEvaluatorTest + { + private static void ConsumeEvaluationTestData(List> testData) + { + foreach ((DateTimeOffset time, TimeWindowFilterSettings settings, bool expected) in testData) + { + Assert.Equal(RecurrenceEvaluator.IsMatch(time, settings), expected); + } + } + + private static void ConsumeEvalutationTestData(List> testData) + { + foreach ((DateTimeOffset time, TimeWindowFilterSettings settings, DateTimeOffset? expected) in testData) + { + DateTimeOffset? res = RecurrenceEvaluator.CalculateClosestStart(time, settings); + + Assert.Equal(expected, res); + } + } + + [Fact] + public void MatchDailyRecurrenceTest() + { + var testData = new List>() + { + ( DateTimeOffset.Parse("2023-9-2T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-2T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-5T00:00:00+08:00"), // Within the recurring time window 2023-9-5T00:00:00+08:00 ~ 2023-9-7T00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 4 + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-6T00:00:00+08:00"), // Within the recurring time window 2023-9-5T00:00:00+08:00 ~ 2023-9-7T00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 4 + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-9T00:00:00+08:00"), // Within the recurring time window 2023-9-9T00:00:00+08:00 ~ 2023-9-11T00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 4 + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Within the recurring time window 2023-9-3T00:00:00+08:00 ~ 2023-9-31T00:00:01+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // The third occurrence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-4T00:00:00+08:00"), // Behind end date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00") + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-2T16:00:00+00:00"), // 2023-9-3T00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-2T15:59:59+00:00"), // 2023-9-2T23:59:59+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + false ), + }; + + ConsumeEvaluationTestData(testData); + } + + [Fact] + public void MatchWeeklyRecurrenceTest() + { + var testData = new List>() + { + ( DateTimeOffset.Parse("2023-9-4T00:00:00+08:00"), // Monday in the 2nd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-8T00:00:00+08:00"), // Friday in the 2nd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-8T00:00:00+08:00"), // Friday in the 2nd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + DaysOfWeek = new List(){ DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-15T00:00:00+08:00"), // Friday in the 3rd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + DaysOfWeek = new List(){ DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-4T00:00:00+08:00"), // Monday is not included in DaysOfWeek. + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-2T00:00:00+08:00"), // The 2nd occurrence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 1 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // The 3rd occurrence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // The 3rd occurrence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-8T00:00:00+08:00"), // The 8th occurence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 7 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-8T00:00:00+08:00"), // The 8th occurence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 8 + } + } + }, + true ), + + ( DateTimeOffset.Parse("2024-1-18T00:30:00+08:00"), // The 4th occurence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-4T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-1-4T01:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Tuesday, DayOfWeek.Thursday, DayOfWeek.Friday}, + Interval = 2 + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2024-1-18T00:30:00+08:00"), // The 4th occurence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-4T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-1-4T01:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Tuesday, DayOfWeek.Thursday, DayOfWeek.Friday}, + Interval = 2 + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 4 + } + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-4T00:00:00+08:00"), // Monday in the 2nd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-3T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, // 2023-9-3 is the last day of the 1st week after the Start date + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-4T00:00:00+08:00"), // Monday in the 1st week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-3T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default, 2023-9-3 ~ 2023-9-9 is the 1st week after the Start date + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-18T00:00:00+08:00"), // Monday in the 4th week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-3T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, // 2023-9-3 1st week, 9-4 ~ 9-10 2nd week (Skipped), 9-11 ~ 9-17 3rd week, 9-18 ~ 9-24 4th week (Skipped) + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-18T00:00:00+08:00"), // Monday in the 3rd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-3T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default, 2023-9-3 ~ 9-9 1st week, 9-17 ~ 9-23 3rd week + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-17T00:00:00+08:00"), // Sunday in the 3rd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-3T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, // 2023-9-3 1st week, 9-11 ~ 9-17 3rd week + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // 2023-9-3, 9-11. 9-17 + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + true ), + + ( DateTimeOffset.Parse("2024-2-12T08:00:00+08:00"), // Monday in the 3rd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-2T12:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2024-2-3T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Sunday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-13T00:00:00+08:00"), // Within the recurring time window 2023-9-11T:00:00:00+08:00 ~ 2023-9-15T:00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-7T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // Time window occurrences: 9-3 ~ 9-7 (1st week), 9-11 ~ 9-15 and 9-17 ~ 9-21 (3rd week) + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-19T00:00:00+08:00"), // The 3rd occurrence: 2023-9-17T:00:00:00+08:00 ~ 2023-9-21T:00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-7T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // Time window occurrences: 9-3 ~ 9-7 (1st week), 9-11 ~ 9-15 and 9-17 ~ 9-21 (3rd week) + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-19T00:00:00+08:00"), // The 3rd occurrence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-7T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // Time window occurrences: 9-3 ~ 9-7 (1st week), 9-11 ~ 9-15 and 9-17 ~ 9-21 (3rd week) + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-3T16:00:00+00:00"), // Monday in the 2nd week after the Start date if timezone is UTC+8 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-7T16:00:00+00:00"), // Friday in the 2nd week after the Start date if timezone is UTC+8 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-3T15:59:59+00:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-7T15:59:59+00:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-10T16:00:00+00:00"), // Within the recurring time window 2023-9-11T:00:00:00+08:00 ~ 2023-9-15T:00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-7T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // Time window occurrences: 9-3 ~ 9-7 (1st week), 9-11 ~ 9-15 and 9-17 ~ 9-21 (3rd week) + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-10T15:59:59+00:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-7T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // Time window occurrences: 9-3 ~ 9-7 (1st week), 9-11 ~ 9-15 and 9-17 ~ 9-21 (3rd week) + }, + Range = new RecurrenceRange() + } + }, + false ) + }; + + ConsumeEvaluationTestData(testData); + } + + [Fact] + public void FindDailyClosestStartTest() + { + var testData = new List>() + { + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-3-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-3-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-3-1T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-28T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-29T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 3 + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-28T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 27 + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 28 + } + } + }, + DateTimeOffset.Parse("2024-2-28T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2024-2-27T00:00:00+08:00") + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2024-2-28T00:00:00+08:00") + } + } + }, + DateTimeOffset.Parse("2024-2-28T00:00:00+08:00")) + }; + + ConsumeEvalutationTestData(testData); + } + + [Fact] + public void FindWeeklyClosestStartTest() + { + var testData = new List>() + { + ( DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-29T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-29T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Thursday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-29T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-29T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-29T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-29T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Thursday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-29T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-29T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Thursday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-29T12:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-3-1T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-3-3T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-29T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-1T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 1 + } + } + }, + DateTimeOffset.Parse("2024-2-1T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-2T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 1 + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-11T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + }, + DateTimeOffset.Parse("2024-2-11T00:00:00+08:00")), // Sunday in the 3rd week + + ( DateTimeOffset.Parse("2024-2-11T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-12T00:00:00+08:00"), // Monday in the 3rd week + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + DateTimeOffset.Parse("2024-2-15T00:00:00+08:00")), // Thursday in the 3rd week + + ( DateTimeOffset.Parse("2024-2-12T00:00:00+08:00"), // Monday in the 3rd week + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + DateTimeOffset.Parse("2024-2-15T00:00:00+08:00")), // Thursday in the 3rd week + + ( DateTimeOffset.Parse("2024-2-11T00:00:00+08:00"), // Sunday in the 3rd week + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-11T00:00:00+08:00"), // Sunday in the 2nd week + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-11T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2024-2-8T00:00:00+08:00") + } + } + }, + null) + }; + + ConsumeEvalutationTestData(testData); + } + + [Fact] + public async void RecurrenceEvaluationThroughCacheTest() + { + OnDemandClock mockedTimeProvider = new OnDemandClock(); + + var mockedTimeWindowFilter = new TimeWindowFilter() + { + Cache = new MemoryCache(new MemoryCacheOptions()), + SystemClock = mockedTimeProvider + }; + + var context = new FeatureFilterEvaluationContext() + { + Settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2024-2-5T12:00:00+08:00") + } + } + } + }; + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T23:00:00+08:00"); + + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + for (int i = 0; i < 12; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + } + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T11:59:59+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T00:00:00+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-7T00:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + for (int i = 0; i < 10; i++ ) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + } + + context = new FeatureFilterEvaluationContext() + { + Settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List() { DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + } + }; + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-1-31T23:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + for (int i = 0; i < 12; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + } + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T11:59:59+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T00:00:00+08:00"); // Friday + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"); // Sunday + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T06:00:00+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T12:01:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-8T00:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + for (int i = 0; i < 10; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + } + } + } +} diff --git a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj index 25148af1..f3d7f100 100644 --- a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj +++ b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj @@ -3,7 +3,7 @@ net48;net6.0;net7.0;net8.0 false - 9.0 + 8.0 True ..\..\build\Microsoft.FeatureManagement.snk