diff --git a/README.md b/README.md
index 49c2ec6f..d980bba4 100644
--- a/README.md
+++ b/README.md
@@ -489,6 +489,52 @@ This filter provides the capability to enable a feature based on a time window.
}
```
+This filter also provides the capability to enable a feature during some recurring time windows. The recurring time windows act as the additional filters of evaluation, supplementing the time window defined by the `Start` and `End`. If any recurring time window filter is specified, the feature will only be enabled when the current time falls between the `Start` and `End`, and also falls within a recurring time window listed in the `Filters`.
+We can see some Crontab expressions are listed under the `Filters`, which specify the recurring time windows.
+``` JavaScript
+"EnhancedPipeline": {
+ "EnabledFor": [
+ {
+ "Name": "Microsoft.TimeWindow",
+ "Parameters": {
+ "Start": "2023-09-06T00:00:00+08:00",
+ "Filters": [
+ // 18:00-20:00 on weekdays
+ "* 18-19 * * Mon-Fri",
+ // 18:00-22:00 on weekends
+ "* 18-21 * * Sat,Sun"
+ ]
+ }
+ }
+ ]
+}
+```
+In UNIX, the usage of Crontab expression is to represent the time to execute a command. The Crontab serves as a schedule. The Crontab expression is designed to represent discrete time point rather than continuous time span. For example, the Cron job “* * * * * echo hi >> log” will execute “echo hi >> log” at the beginning of each minute.
+
+However, when the time granularity is at minute-level, we can consider it as a continuous time intervals. For example, the Crontab expression “* 16 * * *” means “at every minute past the 16th hour of the day”. We can intuitively consider it as the time span 16:00-17:00. As a result, the recurring time window can be represented by a Crontab expression.
+
+The Crontab format can be refered to the [linux man page](https://man7.org/linux/man-pages/man5/crontab.5.html). We allow the usage of ranges, lists and step values. Randomization is not supported.
+
+The crontab format has five time fields separated by at least one blank:
+
+```
+{minute} {hour} {day-of-month} {month} {day-of-week}
+```
+
+| Crontab Expression Example | Recurring Time Window |
+|--------|--------|
+|* 16 * * 1-5 | 16:00-17:00 every weekday |
+| * 6-9,16-19 * * * | 6:00-10:00 and 16:00-20:00 every day |
+| * * 1,15 * 1 | the first and fifteenth day of each month, as well as on every Monday|
+| * * * 8 5 | every Friday in August |
+| * 18-21 25 12 * | 18:00-22:00 on Dec 25th |
+
+The Crontab does not contain the information of UTC time offset.
+- If the UTC offset is specified in `Start`, it will also be applied to Crontabs listed in `Filters`.
+- If the UTC offset of `Start` is not specified and the UTC offset of `End` is specified, the UTC offset of `End` will be applied to Crontabs listed in `Filters`.
+- Otherwise, we will use the default UTC offset: UTC+0:00.
+
+In the above example, the `Start` is "2023-09-06T00:00:00+08:00", as a result, the UTC+8:00 will be also applied to the recurring time window filters: 18:00-20:00 on weekdays and 18:00-22:00 on weekends.
#### 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.
diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Cron/CronExpression.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Cron/CronExpression.cs
new file mode 100644
index 00000000..b994dd9c
--- /dev/null
+++ b/src/Microsoft.FeatureManagement/FeatureFilters/Cron/CronExpression.cs
@@ -0,0 +1,121 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+using System;
+
+namespace Microsoft.FeatureManagement.FeatureFilters.Cron
+{
+ ///
+ /// The Cron expression with Minute, Hour, Day of month, Month, Day of week fields.
+ ///
+ internal class CronExpression
+ {
+ private static readonly int NumberOfFields = 5;
+
+ private readonly CronField _minute;
+ private readonly CronField _hour;
+ private readonly CronField _dayOfMonth;
+ private readonly CronField _month;
+ private readonly CronField _dayOfWeek;
+
+ private CronExpression(CronField minute, CronField hour, CronField dayOfMonth, CronField month, CronField dayOfWeek)
+ {
+ _minute = minute;
+ _hour = hour;
+ _dayOfMonth = dayOfMonth;
+ _month = month;
+ _dayOfWeek = dayOfWeek;
+ }
+
+ ///
+ /// Check whether the given expression can be parsed by a CronExpression.
+ /// If the expression is invalid, an ArgumentException will be thrown.
+ ///
+ /// The expression to parse.
+ /// A parsed CronExpression.
+ public static CronExpression Parse(string expression)
+ {
+ if (expression == null)
+ {
+ throw new ArgumentNullException(nameof(expression));
+ }
+
+ string InvalidCronExpressionErrorMessage = $"The provided Cron expression: '{expression}' is invalid.";
+
+ var fields = new string[NumberOfFields];
+
+ int i = 0, pos = 0;
+
+ while (i < expression.Length)
+ {
+ if (char.IsWhiteSpace(expression[i]))
+ {
+ i++;
+
+ continue;
+ }
+
+ if (pos >= fields.Length)
+ {
+ throw new ArgumentException(InvalidCronExpressionErrorMessage, nameof(expression));
+ }
+
+ int start = i; // Start of a field
+
+ while (i < expression.Length && !char.IsWhiteSpace(expression[i]))
+ {
+ i++;
+ }
+
+ fields[pos] = expression.Substring(start, i - start);
+
+ pos++;
+ }
+
+ if (CronField.TryParse(CronFieldKind.Minute, fields[0], out CronField minute) &&
+ CronField.TryParse(CronFieldKind.Hour, fields[1], out CronField hour) &&
+ CronField.TryParse(CronFieldKind.DayOfMonth, fields[2], out CronField dayOfMonth) &&
+ CronField.TryParse(CronFieldKind.Month, fields[3], out CronField month) &&
+ CronField.TryParse(CronFieldKind.DayOfWeek, fields[4], out CronField dayOfWeek))
+ {
+ return new CronExpression(minute, hour, dayOfMonth, month, dayOfWeek);
+ }
+ else
+ {
+ throw new ArgumentException(InvalidCronExpressionErrorMessage, nameof(expression));
+ }
+ }
+
+ ///
+ /// Checks whether the Cron expression is satisfied by the given timestamp.
+ ///
+ /// The timestamp to check.
+ /// True if the Cron expression is satisfied by the give timestamp, otherwise false.
+ public bool IsSatisfiedBy(DateTimeOffset time)
+ {
+ /*
+ The current time is said to be satisfied by the Cron expression when the 'minute', 'hour', and 'month of the year' fields match the current time,
+ and at least one of the two 'day' fields ('day of month', or 'day of week') match the current time.
+ If both 'day' fields are restricted (i.e., do not contain the "*" character), the current time will be considered as satisfied when it match either 'day' field and other fields.
+ If exactly one of 'day' fields are restricted, the current time will be considered as satisfied when it match both 'day' fields and other fields.
+ */
+ bool isDayMatched;
+
+ if (!_dayOfMonth.MatchesAll && !_dayOfWeek.MatchesAll)
+ {
+ isDayMatched = (_dayOfMonth.Match((int)time.Day) || _dayOfWeek.Match((int)time.DayOfWeek)) &&
+ _month.Match((int)time.Month);
+ }
+ else
+ {
+ isDayMatched = _dayOfMonth.Match((int)time.Day) &&
+ _dayOfWeek.Match((int)time.DayOfWeek) &&
+ _month.Match((int)time.Month);
+ }
+
+ return isDayMatched &&
+ _hour.Match((int)time.Hour) &&
+ _minute.Match((int)time.Minute);
+ }
+ }
+}
diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Cron/CronField.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Cron/CronField.cs
new file mode 100644
index 00000000..b477ab94
--- /dev/null
+++ b/src/Microsoft.FeatureManagement/FeatureFilters/Cron/CronField.cs
@@ -0,0 +1,310 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+using System;
+using System.Collections;
+
+namespace Microsoft.FeatureManagement.FeatureFilters.Cron
+{
+ ///
+ /// The CronField uses a BitArray to record which values are included in the field.
+ ///
+ internal class CronField
+ {
+ private readonly CronFieldKind _kind;
+ private readonly BitArray _bits;
+
+ ///
+ /// Initialize the BitArray.
+ ///
+ /// The CronField kind.
+ public CronField(CronFieldKind kind)
+ {
+ _kind = kind;
+
+ (int minValue, int maxValue) = GetFieldRange(kind);
+
+ _bits = new BitArray(maxValue - minValue + 1);
+ }
+
+ ///
+ /// Whether the CronField is an asterisk.
+ ///
+ public bool MatchesAll { get; private set; }
+
+ ///
+ /// Checks whether the field matches the give value.
+ ///
+ /// The value to match.
+ /// True if the value is matched, otherwise false.
+ public bool Match(int value)
+ {
+ if (!IsValueValid(_kind, value))
+ {
+ return false;
+ }
+ else
+ {
+ if (MatchesAll)
+ {
+ return true;
+ }
+
+ if (_kind == CronFieldKind.DayOfWeek && value == 0) // Corner case for Sunday: both 0 and 7 can be interpreted to Sunday
+ {
+ return _bits[0] | _bits[7];
+ }
+
+ return _bits[ValueToIndex(_kind, value)];
+ }
+ }
+
+ ///
+ /// Checks whether the given content can be fit into the CronField.
+ ///
+ /// The CronField kind.
+ /// The content to parse.
+ /// The parsed number.
+ /// True if the content is valid and parsed successfully, otherwise false.
+ public static bool TryParse(CronFieldKind kind, string content, out CronField result)
+ {
+ result = null;
+
+ if (content == null)
+ {
+ return false;
+ }
+
+ CronField cronField = new CronField(kind);
+
+ //
+ // The field can be a list which is a set of numbers or ranges separated by commas.
+ // Ranges are two numbers/names separated with a hyphen or an asterisk which represents all possible values in the field.
+ // Step values can be used in conjunction with ranges after a slash.
+ string[] segments = content.Split(',');
+
+ foreach (string segment in segments)
+ {
+ if (segment == null)
+ {
+ return false;
+ }
+
+ if (TryGetNumber(kind, segment, out int value) == true)
+ {
+ int temp = ValueToIndex(kind, value);
+
+ cronField._bits[temp] = true;
+
+ continue;
+ }
+
+ if (segment.Contains("-") || segment.Contains("*")) // The segment might be a range.
+ {
+ if (string.Equals(segment, "*"))
+ {
+ cronField.MatchesAll = true;
+ }
+
+ string[] parts = segment.Split('/');
+
+ int step = 1;
+
+ if (parts.Length > 2) // multiple slashs
+ {
+ return false;
+ }
+
+ if (parts.Length == 2)
+ {
+ if (int.TryParse(parts[1], out step) == false || step <= 0)
+ {
+ return false;
+ }
+ }
+
+ string range = parts[0];
+
+ int first, last;
+
+ if (string.Equals(range, "*")) // asterisk represents unrestricted range
+ {
+ (first, last) = GetFieldRange(kind);
+ }
+ else // range should be defined by two numbers separated with a hyphen
+ {
+ string[] numbers = range.Split('-');
+
+ if (numbers.Length != 2)
+ {
+ return false;
+ }
+
+ if (TryGetNumber(kind, numbers[0], out first) == false || TryGetNumber(kind, numbers[1], out last) == false)
+ {
+ return false;
+ }
+
+ if (cronField._kind == CronFieldKind.DayOfWeek && last == 0 && last != first) // Corner case for Sunday: both 0 and 7 can be interpreted to Sunday
+ {
+ last = 7; // Mon-Sun should be intepreted to 1-7 instead of 1-0
+ }
+
+ if (first > last)
+ {
+ return false;
+ }
+ }
+
+ for (int num = first; num <= last; num += step)
+ {
+ cronField._bits[ValueToIndex(kind, num)] = true;
+ }
+ }
+ else // The segment is neither a range nor a valid number.
+ {
+ return false;
+ }
+ }
+
+ result = cronField;
+
+ return true;
+ }
+
+ private static (int, int) GetFieldRange(CronFieldKind kind)
+ {
+ (int minValue, int maxValue) = kind switch
+ {
+ CronFieldKind.Minute => (0, 59),
+ CronFieldKind.Hour => (0, 23),
+ CronFieldKind.DayOfMonth => (1, 31),
+ CronFieldKind.Month => (1, 12),
+ CronFieldKind.DayOfWeek => (0, 7),
+ _ => throw new ArgumentException("Invalid Cron field kind.", nameof(kind))
+ };
+
+ return (minValue, maxValue);
+ }
+
+ private static bool TryGetNumber(CronFieldKind kind, string str, out int number)
+ {
+ if (str == null)
+ {
+ number = -1;
+
+ return false;
+ }
+
+ if (int.TryParse(str, out number) == true)
+ {
+ return IsValueValid(kind, number);
+ }
+ else
+ {
+ if (kind == CronFieldKind.Month)
+ {
+ return TryGetMonthNumber(str, out number);
+ }
+ else if (kind == CronFieldKind.DayOfWeek)
+ {
+ return TryGetDayOfWeekNumber(str, out number);
+ }
+ else
+ {
+ number = -1;
+
+ return false;
+ }
+ }
+ }
+
+ private static bool TryGetMonthNumber(string name, out int number)
+ {
+ if (name == null)
+ {
+ number = -1;
+
+ return false;
+ }
+
+ number = name.ToUpper() switch
+ {
+ "JAN" => 1,
+ "FEB" => 2,
+ "MAR" => 3,
+ "APR" => 4,
+ "MAY" => 5,
+ "JUN" => 6,
+ "JUL" => 7,
+ "AUG" => 8,
+ "SEP" => 9,
+ "OCT" => 10,
+ "NOV" => 11,
+ "DEC" => 12,
+ _ => -1
+ };
+
+ if (number == -1)
+ {
+ return false;
+ }
+ else
+ {
+ return true;
+ }
+ }
+
+ private static bool TryGetDayOfWeekNumber(string name, out int number)
+ {
+ if (name == null)
+ {
+ number = -1;
+
+ return false;
+ }
+
+ number = name.ToUpper() switch
+ {
+ "SUN" => 0,
+ "MON" => 1,
+ "TUE" => 2,
+ "WED" => 3,
+ "THU" => 4,
+ "FRI" => 5,
+ "SAT" => 6,
+ _ => -1
+ };
+
+ if (number == -1)
+ {
+ return false;
+ }
+ else
+ {
+ return true;
+ }
+ }
+
+ private static bool IsValueValid(CronFieldKind kind, int value)
+ {
+ (int minValue, int maxValue) = GetFieldRange(kind);
+
+ return (value >= minValue) && (value <= maxValue);
+ }
+
+ private static int ValueToIndex(CronFieldKind kind, int value)
+ {
+ string ValueOutOfRangeErrorMessage = $"Value is out of the range of {kind} field.";
+
+ if (!IsValueValid(kind, value))
+ {
+ throw new ArgumentException(ValueOutOfRangeErrorMessage, nameof(value));
+ }
+
+ (int minValue, int _) = GetFieldRange(kind);
+
+ return value - minValue;
+ }
+ }
+}
diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Cron/CronFieldKind.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Cron/CronFieldKind.cs
new file mode 100644
index 00000000..78103b8c
--- /dev/null
+++ b/src/Microsoft.FeatureManagement/FeatureFilters/Cron/CronFieldKind.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+namespace Microsoft.FeatureManagement.FeatureFilters.Cron
+{
+ ///
+ /// Define an enum of all required fields of the Cron expression.
+ ///
+ internal enum CronFieldKind
+ {
+ ///
+ /// Field Name: Minute
+ /// Allowed Values: 0-59
+ ///
+ Minute,
+
+ ///
+ /// Field Name: Hour
+ /// Allowed Values: 1-12
+ ///
+ Hour,
+
+ ///
+ /// Field Name: Day of month
+ /// Allowed Values: 1-31
+ ///
+ DayOfMonth,
+
+ ///
+ /// Field Name: Month
+ /// Allowed Values: 1-12 (or use the first three letters of the month name)
+ ///
+ Month,
+
+ ///
+ /// Field Name: Day of week
+ /// Allowed Values: 0-7 (0 or 7 is Sunday, or use the first three letters of the day name)
+ ///
+ DayOfWeek
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs
index 884332b3..89fc8504 100644
--- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs
+++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs
@@ -3,13 +3,17 @@
//
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
+using Microsoft.FeatureManagement.FeatureFilters.Cron;
using System;
+using System.Linq;
using System.Threading.Tasks;
namespace Microsoft.FeatureManagement.FeatureFilters
{
///
- /// A feature filter that can be used to activate a feature based on a time window.
+ /// A feature filter that can be used to activate a feature based on a singular or recurring time window.
+ /// It supports activating the feature flag during a fixed time window,
+ /// and also allows for configuring recurring time window filters to activate the feature flag periodically.
///
[FilterAlias(Alias)]
public class TimeWindowFilter : IFeatureFilter, IFilterParametersBinder
@@ -49,14 +53,48 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context)
DateTimeOffset now = DateTimeOffset.UtcNow;
- if (!settings.Start.HasValue && !settings.End.HasValue)
+ if (!settings.Start.HasValue && !settings.End.HasValue && (settings.Filters == null || !settings.Filters.Any()))
{
- _logger.LogWarning($"The '{Alias}' feature filter is not valid for feature '{context.FeatureName}'. It must have have specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both.");
+ _logger.LogWarning($"The '{Alias}' feature filter is not valid for feature '{context.FeatureName}'. It must specify at least one of '{nameof(settings.Start)}', '{nameof(settings.End)}' or '{nameof(settings.Filters)}'.");
return Task.FromResult(false);
}
- return Task.FromResult((!settings.Start.HasValue || now >= settings.Start.Value) && (!settings.End.HasValue || now < settings.End.Value));
+ bool enabled = (!settings.Start.HasValue || now >= settings.Start.Value) && (!settings.End.HasValue || now < settings.End.Value);
+
+ if (!enabled)
+ {
+ return Task.FromResult(false);
+ }
+
+ //
+ // If any recurring time window filter is specified, to activate the feature flag, the current time also needs to be within at least one of the recurring time windows.
+ if (settings.Filters != null && settings.Filters.Any())
+ {
+ enabled = false;
+
+ TimeSpan utcOffsetForCron = new TimeSpan(0, 0, 0); // By default, the UTC offset is UTC+00:00.
+ utcOffsetForCron = settings.Start.HasValue
+ ? settings.Start.Value.Offset
+ : settings.End.HasValue
+ ? settings.End.Value.Offset
+ : utcOffsetForCron;
+
+ DateTimeOffset nowForCron = now + utcOffsetForCron;
+
+ foreach (string expression in settings.Filters)
+ {
+ CronExpression cronExpression = CronExpression.Parse(expression);
+ if (cronExpression.IsSatisfiedBy(nowForCron))
+ {
+ enabled = true;
+
+ break;
+ }
+ }
+ }
+
+ return Task.FromResult(enabled);
}
}
}
diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs
index 41f87cf3..e7eb9c5f 100644
--- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs
+++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT license.
//
using System;
+using System.Collections.Generic;
namespace Microsoft.FeatureManagement.FeatureFilters
{
@@ -14,12 +15,17 @@ 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; }
+
+ ///
+ /// An optional list of Cron expressions that can be used to filter out what time within the time window is applicable.
+ ///
+ public IEnumerable Filters { get; set; }
}
}
diff --git a/tests/Tests.FeatureManagement/Cron.cs b/tests/Tests.FeatureManagement/Cron.cs
new file mode 100644
index 00000000..025bb066
--- /dev/null
+++ b/tests/Tests.FeatureManagement/Cron.cs
@@ -0,0 +1,153 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+//
+using Microsoft.FeatureManagement.FeatureFilters.Cron;
+using System;
+using Xunit;
+
+namespace Tests.FeatureManagement
+{
+ public class CronTest
+ {
+ [Fact]
+ public void CronFieldTest()
+ {
+ Assert.True(CronField.TryParse(CronFieldKind.Minute, "0", out CronField minuteField));
+ Assert.True(minuteField.Match(0));
+ for (int i = 1; i < 60; i++)
+ {
+ Assert.False(minuteField.Match(i));
+ }
+
+ Assert.True(CronField.TryParse(CronFieldKind.Hour, "*/2", out CronField hourField));
+ Assert.False(hourField.MatchesAll);
+ for (int i = 0; i < 24; i++)
+ {
+ if (i % 2 == 0)
+ {
+ Assert.True(hourField.Match(i));
+ }
+ else
+ {
+ Assert.False(hourField.Match(i));
+ }
+ }
+
+ Assert.True(CronField.TryParse(CronFieldKind.DayOfMonth, "1-3", out CronField dayOfMonthField));
+ Assert.True(dayOfMonthField.Match(1));
+ Assert.True(dayOfMonthField.Match(2));
+ Assert.True(dayOfMonthField.Match(3));
+ for (int i = 4; i < 32; i++)
+ {
+ Assert.False(dayOfMonthField.Match(i));
+ }
+
+ Assert.True(CronField.TryParse(CronFieldKind.Month, "1,2", out CronField monthField));
+ Assert.True(monthField.Match(1));
+ Assert.True(monthField.Match(2));
+ for (int i = 3; i < 13; i++)
+ {
+ Assert.False(monthField.Match(i));
+ }
+
+ Assert.True(CronField.TryParse(CronFieldKind.DayOfWeek, "7", out CronField dayOfWeekField));
+ Assert.True(dayOfWeekField.Match(0));
+ for (int i = 1; i < 7; i++)
+ {
+ Assert.False(dayOfWeekField.Match(i));
+ }
+ }
+
+ [Theory]
+ [InlineData("* * * * *", true)]
+ [InlineData("1 2 3 Apr Fri", true)]
+ [InlineData("00-59/3,1,1,1,2-2 01,3,20-23,*,* */10,1-31/100 Apr,1-Feb,oct-DEC/1 Sun-Sat/2,Mon-Sun,0-7", true)]
+ [InlineData("* * 2-1 * *", false)]
+ [InlineData("Fri * * * *", false)]
+ [InlineData("1 2 Wed 4 5", false)]
+ [InlineData("* * * * * *", false)]
+ [InlineData("* * * 1,2", false)]
+ [InlineData("* * * 1, *", false)]
+ [InlineData("* * * ,2 *", false)]
+ [InlineData("* * , * *", false)]
+ [InlineData("* * */-1 * *", false)]
+ [InlineData("* * */0 * *", false)]
+ [InlineData("*****", false)]
+ [InlineData(" * * * * * ", true)]
+ [InlineData("* * * # *", false)]
+ [InlineData("0-60 * * * *", false)]
+ [InlineData("* 24 * * *", false)]
+ [InlineData("* * 32 * *", false)]
+ [InlineData("* * * 13 *", false)]
+ [InlineData("* * * * 8", false)]
+ [InlineData("* * * * */Tue", false)]
+ [InlineData("* * 0 * *", false)]
+ [InlineData("* * * 0 *", false)]
+ [InlineData("* * * */ *", false)]
+ [InlineData("* * * *// *", false)]
+ [InlineData("* * * --- *", false)]
+ [InlineData("* * 1-/2 * *", false)]
+ [InlineData("* * 1-*/2 * *", false)]
+ [InlineData("* * * - *", false)]
+ public void ValidateCronExpressionTest(string expression, bool expected)
+ {
+ bool result = ValidateExpression(expression);
+
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("* * * * *", "Mon, 28 Aug 2023 21:00:00 +08:00", true)]
+ [InlineData("* * * * 0", "Sun, 3 Sep 2023 21:00:00 +08:00", true)]
+ [InlineData("* * * * 7", "Sun, 3 Sep 2023 21:00:00 +08:00", true)]
+ [InlineData("* * * 9 *", "2023-09-13T14:30:00+08:00", true)]
+ [InlineData("* * 13 * *", "2023-09-13T14:30:00+08:00", true)]
+ [InlineData("* * 13 9 *", "2023-09-13T14:30:00+08:00", true)]
+ [InlineData("* * 13 8 *", "2023-09-13T14:30:00+08:00", false)]
+ [InlineData("0 21 13 9 */2", "Tue, 12 Sep 2023 21:00:30 +08:00", true)]
+ [InlineData("* * 13 9 *,1", "Mon, 11 Sep 2023 21:00:30 +08:00", false)]
+ [InlineData("* * 13 9 0-6", "Mon, 11 Sep 2023 21:00:30 +08:00", true)]
+ [InlineData("* * 13 9 Mon", "Wed, 13 Sep 2023 21:00:30 +08:00", true)]
+ [InlineData("* * * * */2", "Sun, 3 Sep 2023 21:00:00 +08:00", true)]
+ [InlineData("* * * * */2", "Mon, 4 Sep 2023 21:00:00 +08:00", false)]
+ [InlineData("* * 4 * */2", "Mon, 4 Sep 2023 21:00:00 +08:00", true)]
+ [InlineData("0 21 31 Aug Mon", "Thu, 31 Aug 2023 21:00:30 +08:00", true)]
+ [InlineData("* 16-19 31 Aug Thu", "Thu, 31 Aug 2023 21:00:00 +08:00", false)]
+ [InlineData("* * * 8 2", "Tue, 12 Sep 2023 21:00:30 +08:00", false)]
+ [InlineData("00 21 30 * 0-6/2", "Tue, 12 Sep 2023 21:00:30 +08:00", true)]
+ [InlineData("0-29 20-23 1,2,15-30/2 Jun-Sep Mon", "Wed, 30 Aug 2023 21:00:00 +08:00", false)]
+ [InlineData("0-29 20-23 1,2,15-30/2 8,1-2,Sep,12 Mon", "2023-09-29T20:00:00+08:00", true)]
+ [InlineData("0-29 21 * * *", "Thu, 31 Aug 2023 21:30:00 +08:00", false)]
+ [InlineData("* * 2 9 0-Sat", "Fri, 1 Sep 2023 21:00:00 +08:00", true)]
+ public void IsCronExpressionSatisfiedByTimeTest(string expression, string timeString, bool expected)
+ {
+ Assert.True(ValidateExpression(expression));
+
+ bool result = IsCronExpressionSatisfiedByTime(expression, timeString);
+
+ Assert.Equal(expected, result);
+ }
+
+ private bool ValidateExpression(string expression)
+ {
+ try
+ {
+ CronExpression cronExpression = CronExpression.Parse(expression);
+ return true;
+ }
+ catch (Exception _)
+ {
+ return false;
+ }
+ }
+
+ private bool IsCronExpressionSatisfiedByTime(string expression, string timeString)
+ {
+ DateTimeOffset dateTimeOffset = DateTimeOffset.Parse(timeString);
+
+ CronExpression cronExpression = CronExpression.Parse(expression);
+
+ return cronExpression.IsSatisfiedBy(dateTimeOffset);
+ }
+ }
+}
diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs
index 9ecd3e7f..5c6d472b 100644
--- a/tests/Tests.FeatureManagement/FeatureManagement.cs
+++ b/tests/Tests.FeatureManagement/FeatureManagement.cs
@@ -15,6 +15,7 @@
using System.Linq;
using System.Net;
using System.Net.Http;
+using System.Threading;
using System.Threading.Tasks;
using Xunit;
@@ -255,6 +256,9 @@ public async Task TimeWindow()
string feature2 = "feature2";
string feature3 = "feature3";
string feature4 = "feature4";
+ string feature5 = "feature5";
+ string feature6 = "feature6";
+ string feature7 = "feature7";
Environment.SetEnvironmentVariable($"FeatureManagement:{feature1}:EnabledFor:0:Name", "TimeWindow");
Environment.SetEnvironmentVariable($"FeatureManagement:{feature1}:EnabledFor:0:Parameters:End", DateTimeOffset.UtcNow.AddDays(1).ToString("r"));
@@ -268,6 +272,18 @@ public async Task TimeWindow()
Environment.SetEnvironmentVariable($"FeatureManagement:{feature4}:EnabledFor:0:Name", "TimeWindow");
Environment.SetEnvironmentVariable($"FeatureManagement:{feature4}:EnabledFor:0:Parameters:Start", DateTimeOffset.UtcNow.AddDays(1).ToString("r"));
+ Environment.SetEnvironmentVariable($"FeatureManagement:{feature5}:EnabledFor:0:Name", "TimeWindow");
+ Environment.SetEnvironmentVariable($"FeatureManagement:{feature5}:EnabledFor:0:Parameters:Filters:0", "* * * * Mon-Fri");
+ Environment.SetEnvironmentVariable($"FeatureManagement:{feature5}:EnabledFor:0:Parameters:Filters:1", "* * * * Sat");
+ Environment.SetEnvironmentVariable($"FeatureManagement:{feature5}:EnabledFor:0:Parameters:Filters:2", "* * * * Sun");
+
+ Environment.SetEnvironmentVariable($"FeatureManagement:{feature6}:EnabledFor:0:Name", "TimeWindow");
+ Environment.SetEnvironmentVariable($"FeatureManagement:{feature6}:EnabledFor:0:Parameters:End", DateTimeOffset.UtcNow.ToString("r"));
+ Environment.SetEnvironmentVariable($"FeatureManagement:{feature6}:EnabledFor:0:Parameters:Filters:0", "* * * * *");
+
+ Environment.SetEnvironmentVariable($"FeatureManagement:{feature7}:EnabledFor:0:Name", "TimeWindow");
+ Environment.SetEnvironmentVariable($"FeatureManagement:{feature7}:EnabledFor:0:Parameters:Filters:0", "*/2 * * * *");
+
IConfiguration config = new ConfigurationBuilder().AddEnvironmentVariables().Build();
var serviceCollection = new ServiceCollection();
@@ -284,6 +300,14 @@ 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));
+
+ bool IsEnabled = await featureManager.IsEnabledAsync(feature7);
+ Thread.Sleep(60000);
+ bool IsEnabledAfterOneMinute = await featureManager.IsEnabledAsync(feature7);
+ Assert.False(IsEnabled && IsEnabledAfterOneMinute);
+ Assert.True(IsEnabled || IsEnabledAfterOneMinute);
}
[Fact]