From 0b5c81c9f16deb6daffa12c3a2f7479b918e536d Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Tue, 2 Apr 2024 15:57:07 +0800 Subject: [PATCH 1/8] Add built-in TargetingFilter impl --- src/featureManager.ts | 3 +- src/filter/TargetingFilter.ts | 128 ++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/filter/TargetingFilter.ts diff --git a/src/featureManager.ts b/src/featureManager.ts index 0da412b..10d6734 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -5,6 +5,7 @@ import { TimewindowFilter } from "./filter/TimeWindowFilter"; import { IFeatureFilter } from "./filter/FeatureFilter"; import { RequirementType } from "./model"; import { IFeatureFlagProvider } from "./featureProvider"; +import { TargetingFilter } from "./filter/TargetingFilter"; export class FeatureManager { #provider: IFeatureFlagProvider; @@ -13,7 +14,7 @@ export class FeatureManager { constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) { this.#provider = provider; - const builtinFilters = [new TimewindowFilter()]; // TODO: add TargetFilter as built-in filter. + const builtinFilters = [new TimewindowFilter(), new TargetingFilter()]; // If a custom filter shares a name with an existing filter, the custom filter overrides the existing one. for (const filter of [...builtinFilters, ...(options?.customFilters ?? [])]) { diff --git a/src/filter/TargetingFilter.ts b/src/filter/TargetingFilter.ts new file mode 100644 index 0000000..49c7e86 --- /dev/null +++ b/src/filter/TargetingFilter.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { IFeatureFilter } from "./FeatureFilter"; +import { createHash } from "crypto"; + +type TargetingFilterParameters = { + Audience: { + DefaultRolloutPercentage: number; + Users?: string[]; + Groups?: { + Name: string; + RolloutPercentage: number; + }[]; + Exclusion?: { + Users?: string[]; + Groups?: string[]; + }; + } +} + +type TargetingFilterEvaluationContext = { + featureName: string; + parameters: TargetingFilterParameters; +} + +type TargetingFilterAppContext = { + userId?: string; + groups?: string[]; +} + +export class TargetingFilter implements IFeatureFilter { + name: string = "Microsoft.Targeting"; + + evaluate(context?: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): boolean | Promise { + if (context === undefined) { + throw new Error("The context is required."); + } + + const { featureName, parameters } = context; + + if (parameters.Audience.Exclusion !== undefined) { + // check if the user is in the exclusion list + if (appContext?.userId !== undefined && + parameters.Audience.Exclusion.Users !== undefined && + parameters.Audience.Exclusion.Users.includes(appContext.userId)) { + return false; + } + // check if the user is in a group within exclusion list + if (appContext?.groups !== undefined && + parameters.Audience.Exclusion.Groups !== undefined) { + for (const excludedGroup of parameters.Audience.Exclusion.Groups) { + if (appContext.groups.includes(excludedGroup)) { + return false; + } + } + } + } + + // check if the user is being targeted directly + if (appContext?.userId !== undefined && + parameters.Audience.Users !== undefined && + parameters.Audience.Users.includes(appContext.userId)) { + return true; + } + + // check if the user is in a group that is being targeted + if (appContext?.groups !== undefined && + parameters.Audience.Groups !== undefined) { + for (const group of parameters.Audience.Groups) { + if (appContext.groups.includes(group.Name)) { + const audienceContextId = constructAudienceContextId(featureName, appContext.userId, group.Name); + const rolloutPercentage = group.RolloutPercentage; + if (TargetingFilter.#isTargeted(audienceContextId, rolloutPercentage)) { + return true; + } + } + } + } + + // check if the user is being targeted by a default rollout percentage + const defaultContextId = constructAudienceContextId(featureName, appContext?.userId); + return TargetingFilter.#isTargeted(defaultContextId, parameters.Audience.DefaultRolloutPercentage); + } + + static #isTargeted(audienceContextId: string, rolloutPercentage: number): boolean { + if (rolloutPercentage === 100) { + return true; + } + // Cryptographic hashing algorithms ensure adequate entropy across hash values. + const contextMarker = stringToUint32(audienceContextId); + const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; + return contextPercentage < rolloutPercentage; + } +} + +/** + * Constructs the context id for the audience. + * The context id is used to determine if the user is part of the audience for a feature. + * If groupName is provided, the context id is constructed as follows: + * userId + "\n" + featureName + "\n" + groupName + * Otherwise, the context id is constructed as follows: + * userId + "\n" + featureName + * + * @param featureName name of the feature + * @param userId userId from app context + * @param groupName group name from app context + * @returns a string that represents the context id for the audience + */ +function constructAudienceContextId(featureName: string, userId: string | undefined, groupName?: string) { + let contextId = `${userId ?? ""}\n${featureName}`; + if (groupName !== undefined) { + contextId += `\n${groupName}`; + } + return contextId +} + +function stringToUint32(str: string): number { + // Create a SHA-256 hash of the string + const hash = createHash("sha256").update(str).digest(); + + // Get the first 4 bytes of the hash + const first4Bytes = hash.subarray(0, 4); + + // Convert the 4 bytes to a uint32 with little-endian encoding + const uint32 = first4Bytes.readUInt32LE(0); + return uint32; +} From ddbcfda1b8777ff7d915999a20bd8a7a9ad40569 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Tue, 9 Apr 2024 12:48:17 +0800 Subject: [PATCH 2/8] add test cases for targeting filter --- test/targetingFilter.test.ts | 85 ++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 test/targetingFilter.test.ts diff --git a/test/targetingFilter.test.ts b/test/targetingFilter.test.ts new file mode 100644 index 0000000..f5d493d --- /dev/null +++ b/test/targetingFilter.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "./exportedApi"; + +const complextTargetingFeature = { + "id": "ComplexTargeting", + "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2. Dave and Stage3 are excluded. The default rollout percentage is 25%.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [ + "Alice" + ], + "Groups": [ + { + "Name": "Stage1", + "RolloutPercentage": 100 + }, + { + "Name": "Stage2", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 25, + "Exclusion": { + "Users": ["Dave"], + "Groups": ["Stage3"] + } + } + } + } + ] + } +}; + +describe("targeting filter", () => { + it("should evaluate feature with targeting filter", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [complextTargetingFeature] + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + return Promise.all([ + // default rollout 25% + // - "Aiden\nComplexTargeting": ~62.9% + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden" })).eventually.eq(false, "Aiden is not in the 25% default rollout"), + // - "Blossom\nComplexTargeting": ~20.2% + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom" })).eventually.eq(true, "Blossom is in the 25% default rollout"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice" })).eventually.eq(true, "Alice is directly targeted"), + + // Stage1 group is 100% rollout + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage1"] })).eventually.eq(true, "Aiden is in because Stage1 is 100% rollout"), + + // Stage2 group is 50% rollout + // - "\nComplexTargeting\nStage2": ~78.7% + expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage2"] })).eventually.eq(false, "Empty user will hit the 50% rollout of group Stage2"), + // - "Aiden\nComplexTargeting\nStage2": ~15.6% + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage2"] })).eventually.eq(true, "Aiden is in the 50% rollout"), + // TODO: + // In the centralized test cases it is Chad here, but "Chad\nComplexTargeting\nStage2": ~33.8%, is in the 50% rollout. + // Need to investigate whether the case or implementation is wrong. + // - "Cad\nComplexTargeting\nStage2": ~80.3% + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Cad", groups: ["Stage2"] })).eventually.eq(false, "Cad is not in the 50% rollout"), + + // exclusions + expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage3"] })).eventually.eq(false, "Stage3 group is excluded"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice", groups: ["Stage3"] })).eventually.eq(false, "Alice is excluded because she is part of Stage3 group"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom", groups: ["Stage3"] })).eventually.eq(false, "Blossom is excluded because she is part of Stage3 group"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Dave", groups: ["Stage1"] })).eventually.eq(false, "Dave is excluded because he is in the exclusion list"), + ]); + }); +}); From ee83ae8fb7249bad53c9f471758c273aa4c52d82 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Tue, 9 Apr 2024 13:56:37 +0800 Subject: [PATCH 3/8] update test cases --- test/targetingFilter.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/test/targetingFilter.test.ts b/test/targetingFilter.test.ts index f5d493d..256fa4c 100644 --- a/test/targetingFilter.test.ts +++ b/test/targetingFilter.test.ts @@ -57,6 +57,7 @@ describe("targeting filter", () => { // default rollout 25% // - "Aiden\nComplexTargeting": ~62.9% expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden" })).eventually.eq(false, "Aiden is not in the 25% default rollout"), + // - "Blossom\nComplexTargeting": ~20.2% expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom" })).eventually.eq(true, "Blossom is in the 25% default rollout"), expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice" })).eventually.eq(true, "Alice is directly targeted"), @@ -65,15 +66,16 @@ describe("targeting filter", () => { expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage1"] })).eventually.eq(true, "Aiden is in because Stage1 is 100% rollout"), // Stage2 group is 50% rollout - // - "\nComplexTargeting\nStage2": ~78.7% - expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage2"] })).eventually.eq(false, "Empty user will hit the 50% rollout of group Stage2"), + // - "\nComplexTargeting\nStage2": ~78.7% >= 50% (Stage2 is 50% rollout) + // - "\nComplexTargeting": ~38.9% >= 25% (default rollout percentage) + expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage2"] })).eventually.eq(false, "Empty user is not in the 50% rollout of group Stage2"), + // - "Aiden\nComplexTargeting\nStage2": ~15.6% - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage2"] })).eventually.eq(true, "Aiden is in the 50% rollout"), - // TODO: - // In the centralized test cases it is Chad here, but "Chad\nComplexTargeting\nStage2": ~33.8%, is in the 50% rollout. - // Need to investigate whether the case or implementation is wrong. - // - "Cad\nComplexTargeting\nStage2": ~80.3% - expect(featureManager.isEnabled("ComplexTargeting", { userId: "Cad", groups: ["Stage2"] })).eventually.eq(false, "Cad is not in the 50% rollout"), + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage2"] })).eventually.eq(true, "Aiden is in the 50% rollout of group Stage2"), + + // - "Chris\nComplexTargeting\nStage2": 55.3% >= 50% (Stage2 is 50% rollout) + // - "Chris\nComplexTargeting": 72.3% >= 25% (default rollout percentage) + expect(featureManager.isEnabled("ComplexTargeting", { userId: "Chris", groups: ["Stage2"] })).eventually.eq(false, "Chris is not in the 50% rollout of group Stage2"), // exclusions expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage3"] })).eventually.eq(false, "Stage3 group is excluded"), From a0d6627ddbc41e0303f5c795feef7fa058c0fa40 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Wed, 10 Apr 2024 10:15:49 +0800 Subject: [PATCH 4/8] Fix typos --- src/featureManager.ts | 4 ++-- src/filter/TimeWindowFilter.ts | 2 +- test/targetingFilter.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/featureManager.ts b/src/featureManager.ts index 10d6734..99b7700 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { TimewindowFilter } from "./filter/TimeWindowFilter"; +import { TimeWindowFilter } from "./filter/TimeWindowFilter"; import { IFeatureFilter } from "./filter/FeatureFilter"; import { RequirementType } from "./model"; import { IFeatureFlagProvider } from "./featureProvider"; @@ -14,7 +14,7 @@ export class FeatureManager { constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) { this.#provider = provider; - const builtinFilters = [new TimewindowFilter(), new TargetingFilter()]; + const builtinFilters = [new TimeWindowFilter(), new TargetingFilter()]; // If a custom filter shares a name with an existing filter, the custom filter overrides the existing one. for (const filter of [...builtinFilters, ...(options?.customFilters ?? [])]) { diff --git a/src/filter/TimeWindowFilter.ts b/src/filter/TimeWindowFilter.ts index bd44688..92d1f24 100644 --- a/src/filter/TimeWindowFilter.ts +++ b/src/filter/TimeWindowFilter.ts @@ -14,7 +14,7 @@ type TimeWindowFilterEvaluationContext = { parameters: TimeWindowParameters; } -export class TimewindowFilter implements IFeatureFilter { +export class TimeWindowFilter implements IFeatureFilter { name: string = "Microsoft.TimeWindow"; evaluate(context: TimeWindowFilterEvaluationContext): boolean { diff --git a/test/targetingFilter.test.ts b/test/targetingFilter.test.ts index 256fa4c..fffff1d 100644 --- a/test/targetingFilter.test.ts +++ b/test/targetingFilter.test.ts @@ -8,7 +8,7 @@ const expect = chai.expect; import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "./exportedApi"; -const complextTargetingFeature = { +const complexTargetingFeature = { "id": "ComplexTargeting", "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2. Dave and Stage3 are excluded. The default rollout percentage is 25%.", "enabled": true, @@ -47,7 +47,7 @@ describe("targeting filter", () => { it("should evaluate feature with targeting filter", () => { const dataSource = new Map(); dataSource.set("feature_management", { - feature_flags: [complextTargetingFeature] + feature_flags: [complexTargetingFeature] }); const provider = new ConfigurationMapFeatureFlagProvider(dataSource); From 9f88e1cbbd66b432da5623c5db14c350eba75c51 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Wed, 10 Apr 2024 10:57:03 +0800 Subject: [PATCH 5/8] Add parameter validation --- src/filter/TargetingFilter.ts | 15 +++++++++++ test/targetingFilter.test.ts | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/filter/TargetingFilter.ts b/src/filter/TargetingFilter.ts index 49c7e86..715d09c 100644 --- a/src/filter/TargetingFilter.ts +++ b/src/filter/TargetingFilter.ts @@ -38,6 +38,7 @@ export class TargetingFilter implements IFeatureFilter { } const { featureName, parameters } = context; + TargetingFilter.#validateParameters(parameters); if (parameters.Audience.Exclusion !== undefined) { // check if the user is in the exclusion list @@ -92,6 +93,20 @@ export class TargetingFilter implements IFeatureFilter { const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; return contextPercentage < rolloutPercentage; } + + static #validateParameters(parameters: TargetingFilterParameters): void { + if (parameters.Audience.DefaultRolloutPercentage < 0 || parameters.Audience.DefaultRolloutPercentage > 100) { + throw new Error("Audience.DefaultRolloutPercentage must be a number between 0 and 100."); + } + // validate RolloutPercentage for each group + if (parameters.Audience.Groups !== undefined) { + for (const group of parameters.Audience.Groups) { + if (group.RolloutPercentage < 0 || group.RolloutPercentage > 100) { + throw new Error(`RolloutPercentage of group ${group.Name} must be a number between 0 and 100.`); + } + } + } + } } /** diff --git a/test/targetingFilter.test.ts b/test/targetingFilter.test.ts index fffff1d..459662b 100644 --- a/test/targetingFilter.test.ts +++ b/test/targetingFilter.test.ts @@ -43,7 +43,54 @@ const complexTargetingFeature = { } }; +const createTargetingFeatureWithRolloutPercentage = (name: string, defaultRolloutPercentage: number, groups?: { Name: string, RolloutPercentage: number }[]) => { + const featureFlag = { + "id": name, + "description": "A feature flag using a targeting filter with invalid parameters.", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "DefaultRolloutPercentage": defaultRolloutPercentage + } + } + } + ] + } + }; + if (groups && groups.length > 0) { + (featureFlag.conditions.client_filters[0].parameters.Audience as any).Groups = groups; + } + return featureFlag; +}; + describe("targeting filter", () => { + it("should validate parameters", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + createTargetingFeatureWithRolloutPercentage("InvalidTargeting1", -1), + createTargetingFeatureWithRolloutPercentage("InvalidTargeting2", 101), + // invalid group rollout percentage + createTargetingFeatureWithRolloutPercentage("InvalidTargeting3", 25, [{ Name: "Stage1", RolloutPercentage: -1 }]), + createTargetingFeatureWithRolloutPercentage("InvalidTargeting4", 25, [{ Name: "Stage1", RolloutPercentage: 101 }]), + ] + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + return Promise.all([ + expect(featureManager.isEnabled("InvalidTargeting1")).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting2")).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting3")).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting4")).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), + ]); + }); + it("should evaluate feature with targeting filter", () => { const dataSource = new Map(); dataSource.set("feature_management", { From de653797a484a40ad6ac56d985293b56ccaabcad Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Fri, 12 Apr 2024 15:02:08 +0800 Subject: [PATCH 6/8] Ensure appContext is provided --- src/filter/TargetingFilter.ts | 10 +++++----- test/targetingFilter.test.ts | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/filter/TargetingFilter.ts b/src/filter/TargetingFilter.ts index 715d09c..3a1153e 100644 --- a/src/filter/TargetingFilter.ts +++ b/src/filter/TargetingFilter.ts @@ -32,14 +32,14 @@ type TargetingFilterAppContext = { export class TargetingFilter implements IFeatureFilter { name: string = "Microsoft.Targeting"; - evaluate(context?: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): boolean | Promise { - if (context === undefined) { - throw new Error("The context is required."); - } - + evaluate(context: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): boolean | Promise { const { featureName, parameters } = context; TargetingFilter.#validateParameters(parameters); + if (appContext === undefined) { + throw new Error("The app context is required for targeting filter."); + } + if (parameters.Audience.Exclusion !== undefined) { // check if the user is in the exclusion list if (appContext?.userId !== undefined && diff --git a/test/targetingFilter.test.ts b/test/targetingFilter.test.ts index 459662b..6d4fb15 100644 --- a/test/targetingFilter.test.ts +++ b/test/targetingFilter.test.ts @@ -84,10 +84,10 @@ describe("targeting filter", () => { const featureManager = new FeatureManager(provider); return Promise.all([ - expect(featureManager.isEnabled("InvalidTargeting1")).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), - expect(featureManager.isEnabled("InvalidTargeting2")).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), - expect(featureManager.isEnabled("InvalidTargeting3")).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), - expect(featureManager.isEnabled("InvalidTargeting4")).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting1", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting2", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting3", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), + expect(featureManager.isEnabled("InvalidTargeting4", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."), ]); }); @@ -131,4 +131,16 @@ describe("targeting filter", () => { expect(featureManager.isEnabled("ComplexTargeting", { userId: "Dave", groups: ["Stage1"] })).eventually.eq(false, "Dave is excluded because he is in the exclusion list"), ]); }); + + it("should throw error if app context is not provided", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [complexTargetingFeature] + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + return expect(featureManager.isEnabled("ComplexTargeting")).eventually.rejectedWith("The app context is required for targeting filter."); + }); }); From bf545cc15c5c03b4ac22484df7294e0f296ca8b7 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Tue, 16 Apr 2024 13:31:29 +0800 Subject: [PATCH 7/8] Add no-trailing-spaces ESLint rule --- .eslintrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc b/.eslintrc index a20d24b..9af943a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -44,6 +44,7 @@ "eol-last": [ "error", "always" - ] + ], + "no-trailing-spaces": "warn" } } \ No newline at end of file From 2f66e46abfecfaf9415e3859c291c75050599664 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Tue, 16 Apr 2024 13:31:51 +0800 Subject: [PATCH 8/8] address comments --- src/filter/FeatureFilter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/filter/FeatureFilter.ts b/src/filter/FeatureFilter.ts index f31f5cd..4c259b9 100644 --- a/src/filter/FeatureFilter.ts +++ b/src/filter/FeatureFilter.ts @@ -3,7 +3,7 @@ export interface IFeatureFilter { name: string; // e.g. Microsoft.TimeWindow - evaluate(context: IFeatureFilterEvaluationContext, appContext?: unknown): Promise | boolean; + evaluate(context: IFeatureFilterEvaluationContext, appContext?: unknown): boolean | Promise; } export interface IFeatureFilterEvaluationContext {