Skip to content

Commit f9e510a

Browse files
WIP: validation finished
1 parent 8fe0efd commit f9e510a

File tree

8 files changed

+326
-13
lines changed

8 files changed

+326
-13
lines changed

src/featureManager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { TimeWindowFilter } from "./filter/TimeWindowFilter.js";
5-
import { IFeatureFilter } from "./filter/FeatureFilter.js";
4+
import { TimeWindowFilter } from "./filter/timeWindowFilter.js";
5+
import { IFeatureFilter } from "./filter/featureFilter.js";
66
import { RequirementType } from "./schema/model.js";
77
import { IFeatureFlagProvider } from "./featureProvider.js";
8-
import { TargetingFilter } from "./filter/TargetingFilter.js";
8+
import { TargetingFilter } from "./filter/targetingFilter.js";
99

1010
export class FeatureManager {
1111
#provider: IFeatureFlagProvider;

src/filter/TargetingFilter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { IFeatureFilter } from "./FeatureFilter.js";
4+
import { IFeatureFilter } from "./featureFilter.js";
55

66
type TargetingFilterParameters = {
77
Audience: {

src/filter/TimeWindowFilter.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { IFeatureFilter } from "./FeatureFilter.js";
4+
import { IFeatureFilter } from "./featureFilter.js";
5+
import { Recurrence } from "./recurrence/model.js";
6+
import { parseRecurrenceParameter } from "./recurrence/validator.js";
7+
import { matchRecurrence } from "./recurrence/evaluator.js";
8+
import { UNRECOGNIZABLE_VALUE_ERROR_MESSAGE, buildInvalidParameterErrorMessage } from "./utils.js";
9+
10+
type TimeWindowFilterEvaluationContext = {
11+
featureName: string;
12+
parameters: TimeWindowParameters;
13+
};
514

6-
// [Start, End)
715
type TimeWindowParameters = {
816
Start?: string;
917
End?: string;
10-
}
18+
Recurrence?: RecurrenceParameter;
19+
};
1120

12-
type TimeWindowFilterEvaluationContext = {
13-
featureName: string;
14-
parameters: TimeWindowParameters;
15-
}
21+
export type RecurrenceParameter = {
22+
Pattern: {
23+
Type: string;
24+
Interval?: number;
25+
DaysOfWeek?: string[];
26+
FirstDayOfWeek?: string;
27+
},
28+
Range: {
29+
Type: string;
30+
EndDate?: string;
31+
NumberOfOccurrences?: number;
32+
}
33+
};
1634

1735
export class TimeWindowFilter implements IFeatureFilter {
1836
name: string = "Microsoft.TimeWindow";
@@ -22,12 +40,41 @@ export class TimeWindowFilter implements IFeatureFilter {
2240
const startTime = parameters.Start !== undefined ? new Date(parameters.Start) : undefined;
2341
const endTime = parameters.End !== undefined ? new Date(parameters.End) : undefined;
2442

43+
const baseErrorMessage = `The ${this.name} feature filter is not valid for feature ${featureName}. `;
44+
2545
if (startTime === undefined && endTime === undefined) {
2646
// If neither start nor end time is specified, then the filter is not applicable.
27-
console.warn(`The ${this.name} feature filter is not valid for feature ${featureName}. It must specify either 'Start', 'End', or both.`);
47+
console.warn(baseErrorMessage + "It must specify either 'Start', 'End', or both.");
2848
return false;
2949
}
50+
51+
if (startTime !== undefined && isNaN(startTime.getTime())) {
52+
console.warn(baseErrorMessage + buildInvalidParameterErrorMessage("Start", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE));
53+
return false;
54+
}
55+
56+
if (endTime !== undefined && isNaN(endTime.getTime())) {
57+
console.warn(baseErrorMessage + buildInvalidParameterErrorMessage("End", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE));
58+
return false;
59+
}
60+
3061
const now = new Date();
31-
return (startTime === undefined || startTime <= now) && (endTime === undefined || now < endTime);
62+
63+
if ((startTime === undefined || startTime <= now) && (endTime === undefined || now < endTime)) {
64+
return true;
65+
}
66+
67+
if (parameters.Recurrence !== undefined) {
68+
let recurrence: Recurrence;
69+
try {
70+
recurrence = parseRecurrenceParameter(startTime, endTime, parameters.Recurrence);
71+
} catch (error) {
72+
console.warn(baseErrorMessage + error.message);
73+
return false;
74+
}
75+
return matchRecurrence(now, recurrence);
76+
}
77+
78+
return false;
3279
}
3380
}

src/filter/recurrence/evaluator.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { Recurrence } from "./model.js";
5+
6+
export function matchRecurrence(time: Date, recurrence: Recurrence): boolean {
7+
if (time < recurrence.StartTime) {
8+
return false;
9+
}
10+
return false;
11+
}

src/filter/recurrence/model.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
export const DAYS_PER_WEEK = 7;
5+
export const ONE_DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
6+
7+
export enum DayOfWeek {
8+
Sunday = 0,
9+
Monday = 1,
10+
Tuesday = 2,
11+
Wednesday = 3,
12+
Thursday = 4,
13+
Friday = 5,
14+
Saturday = 6
15+
}
16+
17+
export enum RecurrencePatternType {
18+
Daily,
19+
Weekly
20+
}
21+
22+
export enum RecurrenceRangeType {
23+
NoEnd,
24+
EndDate,
25+
Numbered
26+
}
27+
28+
export type RecurrencePattern = {
29+
Type: RecurrencePatternType;
30+
Interval: number;
31+
DaysOfWeek?: DayOfWeek[];
32+
FirstDayOfWeek?: DayOfWeek;
33+
};
34+
35+
export type RecurrenceRange = {
36+
Type: RecurrenceRangeType;
37+
EndDate?: Date;
38+
NumberOfOccurrences?: number;
39+
};
40+
41+
export type Recurrence = {
42+
StartTime: Date;
43+
EndTime: Date;
44+
Pattern: RecurrencePattern;
45+
Range: RecurrenceRange;
46+
TimeZoneOffset: number;
47+
};

src/filter/recurrence/utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { DayOfWeek, DAYS_PER_WEEK } from "./model.js";
5+
6+
/**
7+
* Calculates the offset in days between two given days of the week.
8+
* @param day1 A day of week
9+
* @param day2 A day of week
10+
* @returns The number of days to be added to day2 to reach day1
11+
*/
12+
export function calculateWeeklyDayOffset(day1: DayOfWeek, day2: DayOfWeek): number {
13+
return (day1 - day2 + DAYS_PER_WEEK) % DAYS_PER_WEEK;
14+
}
15+
16+
/**
17+
* Sorts a collection of days of week based on their offsets from a specified first day of week.
18+
* @param daysOfWeek A collection of days of week
19+
* @param firstDayOfWeek The first day of week which will be the first element in the sorted result
20+
* @returns The sorted days of week
21+
*/
22+
export function sortDaysOfWeek(daysOfWeek: DayOfWeek[], firstDayOfWeek: DayOfWeek): DayOfWeek[] {
23+
const sortedDaysOfWeek = daysOfWeek.slice();
24+
sortedDaysOfWeek.sort((x, y) => calculateWeeklyDayOffset(x, firstDayOfWeek) - calculateWeeklyDayOffset(y, firstDayOfWeek));
25+
return sortedDaysOfWeek;
26+
}

src/filter/recurrence/validator.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { RecurrenceParameter } from "../timeWindowFilter.js";
5+
import { VALUE_OUT_OF_RANGE_ERROR_MESSAGE, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE, buildInvalidParameterErrorMessage } from "../utils.js";
6+
import { DayOfWeek, Recurrence, RecurrencePattern, RecurrenceRange, RecurrencePatternType, RecurrenceRangeType, DAYS_PER_WEEK, ONE_DAY_IN_MILLISECONDS } from "./model.js";
7+
import { calculateWeeklyDayOffset, sortDaysOfWeek } from "./utils.js";
8+
9+
const START_NOT_MATCHED_ERROR_MESSAGE = "Start date is not a valid first occurrence.";
10+
const TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE = "Time window duration cannot be longer than how frequently it occurs or be longer than 10 years.";
11+
12+
/**
13+
* Parses @see RecurrenceParameter into a @see Recurrence object. If the parameter is invalid, an error will be thrown.
14+
* @param startTime The start time of the base time window
15+
* @param day2 The end time of the base time window
16+
* @param recurrenceParameter The @see RecurrenceParameter to parse
17+
* @param TimeZoneOffset The time zone offset in milliseconds, by default 0
18+
* @returns A @see Recurrence object
19+
*/
20+
export function parseRecurrenceParameter(startTime: Date | undefined, endTime: Date | undefined, recurrenceParameter: RecurrenceParameter, TimeZoneOffset: number = 0): Recurrence {
21+
if (startTime === undefined) {
22+
throw new Error(buildInvalidParameterErrorMessage("Start", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE));
23+
}
24+
if (endTime === undefined) {
25+
throw new Error(buildInvalidParameterErrorMessage("End", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE));
26+
}
27+
if (startTime >= endTime) {
28+
throw new Error(buildInvalidParameterErrorMessage("End", VALUE_OUT_OF_RANGE_ERROR_MESSAGE));
29+
}
30+
const timeWindowDuration = endTime.getTime() - startTime.getTime();
31+
if (timeWindowDuration > 10 * 365 * ONE_DAY_IN_MILLISECONDS) { // time window duration cannot be longer than 10 years
32+
throw new Error(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE));
33+
}
34+
35+
return {
36+
StartTime: startTime,
37+
EndTime: endTime,
38+
Pattern: parseRecurrencePattern(startTime, endTime, recurrenceParameter, TimeZoneOffset),
39+
Range: parseRecurrenceRange(startTime, recurrenceParameter),
40+
TimeZoneOffset: TimeZoneOffset
41+
};
42+
}
43+
44+
function parseRecurrencePattern(startTime: Date, endTime: Date, recurrenceParameter: RecurrenceParameter, TimeZoneOffset: number): RecurrencePattern {
45+
const rawPattern = recurrenceParameter.Pattern;
46+
if (rawPattern === undefined) {
47+
throw new Error(buildInvalidParameterErrorMessage("Pattern", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE));
48+
}
49+
if (rawPattern.Type === undefined) {
50+
throw new Error(buildInvalidParameterErrorMessage("Pattern.Type", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE));
51+
}
52+
const patternType = RecurrencePatternType[rawPattern.Type];
53+
if (patternType === undefined) {
54+
throw new Error(buildInvalidParameterErrorMessage("Pattern.Type", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE));
55+
}
56+
let interval = rawPattern.Interval;
57+
if (interval !== undefined) {
58+
if (typeof interval !== "number") {
59+
throw new Error(buildInvalidParameterErrorMessage("Pattern.Interval", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE));
60+
} else if (interval <= 0 || !Number.isInteger(interval)) {
61+
throw new Error(buildInvalidParameterErrorMessage("Pattern.Interval", VALUE_OUT_OF_RANGE_ERROR_MESSAGE));
62+
}
63+
} else {
64+
interval = 1;
65+
}
66+
const parsedPattern: RecurrencePattern = {
67+
Type: patternType,
68+
Interval: interval
69+
};
70+
const timeWindowDuration = endTime.getTime() - startTime.getTime();
71+
if (patternType === RecurrencePatternType.Daily) {
72+
if (timeWindowDuration > interval * ONE_DAY_IN_MILLISECONDS) {
73+
throw new Error(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE));
74+
}
75+
} else if (patternType === RecurrencePatternType.Weekly) {
76+
let firstDayOfWeek: DayOfWeek;
77+
if (rawPattern.FirstDayOfWeek !== undefined) {
78+
firstDayOfWeek = DayOfWeek[rawPattern.FirstDayOfWeek];
79+
if (firstDayOfWeek === undefined) {
80+
throw new Error(buildInvalidParameterErrorMessage("Pattern.FirstDayOfWeek", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE));
81+
}
82+
}
83+
else {
84+
firstDayOfWeek = DayOfWeek.Sunday;
85+
}
86+
parsedPattern.FirstDayOfWeek = firstDayOfWeek;
87+
88+
if (rawPattern.DaysOfWeek === undefined || rawPattern.DaysOfWeek.length === 0) {
89+
throw new Error(buildInvalidParameterErrorMessage("Pattern.DaysOfWeek", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE));
90+
}
91+
const daysOfWeek = [...new Set(rawPattern.DaysOfWeek.map(day => DayOfWeek[day]))]; // dedup array
92+
if (daysOfWeek.some(day => day === undefined)) {
93+
throw new Error(buildInvalidParameterErrorMessage("Pattern.DaysOfWeek", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE));
94+
}
95+
if (timeWindowDuration > interval * DAYS_PER_WEEK * ONE_DAY_IN_MILLISECONDS ||
96+
!IsDurationCompliantWithDaysOfWeek(timeWindowDuration, interval, daysOfWeek, firstDayOfWeek)) {
97+
throw new Error(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE));
98+
}
99+
parsedPattern.DaysOfWeek = daysOfWeek;
100+
101+
// check whether "Start" is a valid first occurrence
102+
const alignedStartTime = new Date(startTime);
103+
alignedStartTime.setUTCMilliseconds(alignedStartTime.getUTCMilliseconds() + TimeZoneOffset);
104+
if (!daysOfWeek.find(day => day === alignedStartTime.getUTCDay())) {
105+
throw new Error(buildInvalidParameterErrorMessage("Start", START_NOT_MATCHED_ERROR_MESSAGE));
106+
}
107+
}
108+
return parsedPattern;
109+
}
110+
111+
function parseRecurrenceRange(startTime: Date, recurrenceParameter: RecurrenceParameter): RecurrenceRange {
112+
const rawRange = recurrenceParameter.Range;
113+
if (rawRange === undefined) {
114+
throw new Error(buildInvalidParameterErrorMessage("Range", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE));
115+
}
116+
if (rawRange.Type === undefined) {
117+
throw new Error(buildInvalidParameterErrorMessage("Range.Type", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE));
118+
}
119+
const rangeType = RecurrenceRangeType[rawRange.Type];
120+
if (rangeType === undefined) {
121+
throw new Error(buildInvalidParameterErrorMessage("Range.Type", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE));
122+
}
123+
const parsedRange: RecurrenceRange = { Type: rangeType };
124+
if (rangeType === RecurrenceRangeType.EndDate) {
125+
let endDate: Date;
126+
if (rawRange.EndDate !== undefined) {
127+
endDate = new Date(rawRange.EndDate);
128+
if (isNaN(endDate.getTime())) {
129+
throw new Error(buildInvalidParameterErrorMessage("Range.EndDate", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE));
130+
}
131+
if (endDate < startTime) {
132+
throw new Error(buildInvalidParameterErrorMessage("Range.EndDate", VALUE_OUT_OF_RANGE_ERROR_MESSAGE));
133+
}
134+
} else {
135+
endDate = new Date(8.64e15); // the maximum date in ECMAScript: https://262.ecma-international.org/5.1/#sec-15.9.1.1
136+
}
137+
parsedRange.EndDate = endDate;
138+
} else if (rangeType === RecurrenceRangeType.Numbered) {
139+
let numberOfOccurrences = rawRange.NumberOfOccurrences;
140+
if (numberOfOccurrences !== undefined) {
141+
if (typeof numberOfOccurrences !== "number") {
142+
throw new Error(buildInvalidParameterErrorMessage("Range.NumberOfOccurrences", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE));
143+
} else if (numberOfOccurrences <= 0 || !Number.isInteger(numberOfOccurrences)) {
144+
throw new Error(buildInvalidParameterErrorMessage("Range.NumberOfOccurrences", VALUE_OUT_OF_RANGE_ERROR_MESSAGE));
145+
}
146+
} else {
147+
numberOfOccurrences = Number.MAX_SAFE_INTEGER;
148+
}
149+
parsedRange.NumberOfOccurrences = numberOfOccurrences;
150+
}
151+
return parsedRange;
152+
}
153+
154+
function IsDurationCompliantWithDaysOfWeek(duration: number, interval: number, daysOfWeek: DayOfWeek[], firstDayOfWeek: DayOfWeek): boolean {
155+
if (daysOfWeek.length === 1) {
156+
return true;
157+
}
158+
const sortedDaysOfWeek = sortDaysOfWeek(daysOfWeek, firstDayOfWeek);
159+
let prev = sortedDaysOfWeek[0]; // the closest occurrence day to the first day of week
160+
let minGap = DAYS_PER_WEEK * ONE_DAY_IN_MILLISECONDS;
161+
for (let i = 1; i < sortedDaysOfWeek.length; i++) { // skip the first day
162+
const gap = calculateWeeklyDayOffset(sortedDaysOfWeek[i], prev) * ONE_DAY_IN_MILLISECONDS;
163+
minGap = gap < minGap ? gap : minGap;
164+
prev = sortedDaysOfWeek[i];
165+
}
166+
// It may across weeks. Check the next week if the interval is one week.
167+
if (interval == 1) {
168+
const gap = calculateWeeklyDayOffset(sortedDaysOfWeek[0], prev) * ONE_DAY_IN_MILLISECONDS;
169+
minGap = gap < minGap ? gap : minGap;
170+
}
171+
return minGap >= duration;
172+
}

src/filter/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
export const VALUE_OUT_OF_RANGE_ERROR_MESSAGE = "The value is out of the accepted range.";
5+
export const UNRECOGNIZABLE_VALUE_ERROR_MESSAGE = "The value is unrecognizable.";
6+
export const REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE = "Value cannot be undefined or empty.";
7+
8+
export function buildInvalidParameterErrorMessage(parameterName: string, additionalInfo?: string): string {
9+
return `The ${parameterName} parameter is not valid.` + (additionalInfo ?? "");
10+
}

0 commit comments

Comments
 (0)