From 3ba1828360ca6b73777d5d16df5c5110d4f85145 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Wed, 7 Aug 2024 17:10:09 +0800 Subject: [PATCH 1/8] resovle conflicts --- src/featureManager.ts | 19 ++-- src/featureProvider.ts | 2 +- src/{ => schema}/model.ts | 0 src/schema/validator.ts | 189 ++++++++++++++++++++++++++++++++++++++ test/noFilters.test.ts | 2 +- 5 files changed, 200 insertions(+), 12 deletions(-) rename src/{ => schema}/model.ts (100%) create mode 100644 src/schema/validator.ts diff --git a/src/featureManager.ts b/src/featureManager.ts index 402510f..0692265 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -3,9 +3,10 @@ import { TimeWindowFilter } from "./filter/TimeWindowFilter.js"; import { IFeatureFilter } from "./filter/FeatureFilter.js"; -import { RequirementType } from "./model.js"; +import { RequirementType } from "./schema/model.ts"; import { IFeatureFlagProvider } from "./featureProvider.js"; import { TargetingFilter } from "./filter/TargetingFilter.js"; +import { validateFeatureFlag } from "./schema/validator.ts"; export class FeatureManager { #provider: IFeatureFlagProvider; @@ -30,15 +31,12 @@ export class FeatureManager { // If multiple feature flags are found, the first one takes precedence. async isEnabled(featureName: string, context?: unknown): Promise { - const featureFlag = await this.#provider.getFeatureFlag(featureName); + const featureFlag = await this.#getFeatureFlag(featureName); if (featureFlag === undefined) { // If the feature is not found, then it is disabled. return false; } - // Ensure that the feature flag is in the correct format. Feature providers should validate the feature flags, but we do it here as a safeguard. - validateFeatureFlagFormat(featureFlag); - if (featureFlag.enabled !== true) { // If the feature is not explicitly enabled, then it is disabled by default. return false; @@ -75,14 +73,15 @@ export class FeatureManager { return !shortCircuitEvaluationResult; } + async #getFeatureFlag(featureName: string): Promise { + const featureFlag = await this.#provider.getFeatureFlag(featureName); + validateFeatureFlag(featureFlag); + return featureFlag; + } + } interface FeatureManagerOptions { customFilters?: IFeatureFilter[]; } -function validateFeatureFlagFormat(featureFlag: any): void { - if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") { - throw new Error(`Feature flag ${featureFlag.id} has an invalid 'enabled' value.`); - } -} diff --git a/src/featureProvider.ts b/src/featureProvider.ts index c5a1aac..897bd11 100644 --- a/src/featureProvider.ts +++ b/src/featureProvider.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { IGettable } from "./gettable.js"; -import { FeatureFlag, FeatureManagementConfiguration, FEATURE_MANAGEMENT_KEY, FEATURE_FLAGS_KEY } from "./model.js"; +import { FeatureFlag, FeatureManagementConfiguration, FEATURE_MANAGEMENT_KEY, FEATURE_FLAGS_KEY } from "./schema/model.js"; export interface IFeatureFlagProvider { /** diff --git a/src/model.ts b/src/schema/model.ts similarity index 100% rename from src/model.ts rename to src/schema/model.ts diff --git a/src/schema/validator.ts b/src/schema/validator.ts new file mode 100644 index 0000000..f785387 --- /dev/null +++ b/src/schema/validator.ts @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Validates a feature flag object, checking if it conforms to the schema. + * @param featureFlag The feature flag object to validate. + */ +export function validateFeatureFlag(featureFlag: any): void { + if (featureFlag === undefined) { + return; // no-op if feature flag is undefined, indicating that the feature flag is not found + } + if (featureFlag === null || typeof featureFlag !== "object") { // Note: typeof null = "object" + throw new TypeError("Feature flag must be an object."); + } + if (typeof featureFlag.id !== "string") { + throw new TypeError("Feature flag ID must be a string."); + } + if (featureFlag.description !== undefined && typeof featureFlag.description !== "string") { + throw new TypeError("Feature flag description must be a string."); + } + if (featureFlag.display_name !== undefined && typeof featureFlag.display_name !== "string") { + throw new TypeError("Feature flag display name must be a string."); + } + if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") { + throw new TypeError("Feature flag enabled must be a boolean."); + } + if (featureFlag.conditions !== undefined) { + validateFeatureEnablementConditions(featureFlag.conditions); + } + // variants, allocation, and telemetry + if (featureFlag.variants !== undefined) { + validateVariants(featureFlag.variants); + } + if (featureFlag.allocation !== undefined) { + validateVariantAllocation(featureFlag.allocation); + } + if (featureFlag.telemetry !== undefined) { + validateTelemetryOptions(featureFlag.telemetry); + } +} + +function validateFeatureEnablementConditions(conditions: any) { + if (conditions.requirement_type !== undefined && conditions.requirement_type !== "Any" && conditions.requirement_type !== "All") { + throw new TypeError("Feature enablement conditions requirement type must be 'Any' or 'All'."); + } + if (conditions.client_filters !== undefined) { + validateClientFilters(conditions.client_filters); + } +} + +function validateClientFilters(client_filters: any) { + if (!Array.isArray(client_filters)) { + throw new TypeError("Client filters must be an array."); + } + + for (const filter of client_filters) { + if (typeof filter.name !== "string") { + throw new TypeError("Client filter name must be a string."); + } + if (filter.parameters !== undefined && typeof filter.parameters !== "object") { + throw new TypeError("Client filter parameters must be an object."); + } + // validate parameters here if we have schema of specific filters in the future + } +} + +function validateVariants(variants: any) { + if (!Array.isArray(variants)) { + throw new TypeError("Variants must be an array."); + } + + for (const variant of variants) { + if (typeof variant.name !== "string") { + throw new TypeError("Variant name must be a string."); + } + + // skip configuration_value validation as it accepts any type + + // Although configuration_reference is not supported in the current implementation, we validate it here for future compatibility. + if (variant.configuration_reference !== undefined && typeof variant.configuration_reference !== "string") { + throw new TypeError("Variant configuration reference must be a string."); + } + + if (variant.status_override !== undefined && variant.status_override !== "None" && variant.status_override !== "Enabled" && variant.status_override !== "Disabled") { + throw new TypeError("Variant status override must be 'None', 'Enabled', or 'Disabled'."); + } + } +} + +// #region Allocation +function validateVariantAllocation(allocation: any) { + if (typeof allocation !== "object") { + throw new TypeError("Variant allocation must be an object."); + } + + if (allocation.default_when_disabled !== undefined && typeof allocation.default_when_disabled !== "string") { + throw new TypeError("Variant allocation default_when_disabled must be a string."); + } + if (allocation.default_when_enabled !== undefined && typeof allocation.default_when_enabled !== "string") { + throw new TypeError("Variant allocation default_when_enabled must be a string."); + } + if (allocation.user !== undefined) { + validateUserVariantAllocation(allocation.user); + } + if (allocation.group !== undefined) { + validateGroupVariantAllocation(allocation.group); + } + if (allocation.percentile !== undefined) { + validatePercentileVariantAllocation(allocation.percentile); + } + if (allocation.seed !== undefined && typeof allocation.seed !== "string") { + throw new TypeError("Variant allocation seed must be a string."); + } +} + +function validateUserVariantAllocation(UserAllocations: any) { + if (!Array.isArray(UserAllocations)) { + throw new TypeError("User allocations must be an array."); + } + + for (const allocation of UserAllocations) { + if (typeof allocation.variant !== "string") { + throw new TypeError("User allocation variant must be a string."); + } + if (!Array.isArray(allocation.users)) { + throw new TypeError("User allocation users must be an array."); + } + for (const user of allocation.users) { + if (typeof user !== "string") { + throw new TypeError("Elements in User allocation users must be strings."); + } + } + } +} + +function validateGroupVariantAllocation(groupAllocations: any) { + if (!Array.isArray(groupAllocations)) { + throw new TypeError("Group allocations must be an array."); + } + + for (const allocation of groupAllocations) { + if (typeof allocation.variant !== "string") { + throw new TypeError("Group allocation variant must be a string."); + } + if (!Array.isArray(allocation.groups)) { + throw new TypeError("Group allocation groups must be an array."); + } + for (const group of allocation.groups) { + if (typeof group !== "string") { + throw new TypeError("Elements in Group allocation groups must be strings."); + } + } + } +} + +function validatePercentileVariantAllocation(percentileAllocations: any) { + if (!Array.isArray(percentileAllocations)) { + throw new TypeError("Percentile allocations must be an array."); + } + + for (const allocation of percentileAllocations) { + if (typeof allocation.variant !== "string") { + throw new TypeError("Percentile allocation variant must be a string."); + } + if (typeof allocation.from !== "number") { + throw new TypeError("Percentile allocation from must be a number."); + } + if (typeof allocation.to !== "number") { + throw new TypeError("Percentile allocation to must be a number."); + } + } +} +// #endregion + +// #region Telemetry +function validateTelemetryOptions(telemetry: any) { + if (typeof telemetry !== "object") { + throw new TypeError("Telemetry options must be an object."); + } + + if (telemetry.enabled !== undefined && typeof telemetry.enabled !== "boolean") { + throw new TypeError("Telemetry enabled must be a boolean."); + } + if (telemetry.metadata !== undefined && typeof telemetry.metadata !== "object") { + throw new TypeError("Telemetry metadata must be an object."); + } + // TODO: validate metadata keys (string) and values (string), not sure if we need to do this +} +// #endregion diff --git a/test/noFilters.test.ts b/test/noFilters.test.ts index 55639f7..388136c 100644 --- a/test/noFilters.test.ts +++ b/test/noFilters.test.ts @@ -61,7 +61,7 @@ describe("feature flags with no filters", () => { return Promise.all([ expect(featureManager.isEnabled("BooleanTrue")).eventually.eq(true), expect(featureManager.isEnabled("BooleanFalse")).eventually.eq(false), - expect(featureManager.isEnabled("InvalidEnabled")).eventually.rejectedWith("Feature flag InvalidEnabled has an invalid 'enabled' value."), + expect(featureManager.isEnabled("InvalidEnabled")).eventually.rejectedWith("Feature flag enabled must be a boolean."), expect(featureManager.isEnabled("Minimal")).eventually.eq(true), expect(featureManager.isEnabled("NoEnabled")).eventually.eq(false), expect(featureManager.isEnabled("EmptyConditions")).eventually.eq(true) From 42700d92978f321e78637c6c9b5e7834cab1ddef Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Wed, 23 Oct 2024 09:03:35 +0800 Subject: [PATCH 2/8] update validation --- src/schema/validator.ts | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/schema/validator.ts b/src/schema/validator.ts index f785387..d2aaba2 100644 --- a/src/schema/validator.ts +++ b/src/schema/validator.ts @@ -50,10 +50,13 @@ function validateFeatureEnablementConditions(conditions: any) { function validateClientFilters(client_filters: any) { if (!Array.isArray(client_filters)) { - throw new TypeError("Client filters must be an array."); + throw new TypeError("Client filter Collection must be an array."); } for (const filter of client_filters) { + if (typeof filter.type !== "object") { + throw new TypeError("Client filter must be an object."); + } if (typeof filter.name !== "string") { throw new TypeError("Client filter name must be a string."); } @@ -70,17 +73,16 @@ function validateVariants(variants: any) { } for (const variant of variants) { + if (typeof variant !== "object") { + throw new TypeError("Variant must be an object."); + } if (typeof variant.name !== "string") { throw new TypeError("Variant name must be a string."); } - // skip configuration_value validation as it accepts any type - - // Although configuration_reference is not supported in the current implementation, we validate it here for future compatibility. - if (variant.configuration_reference !== undefined && typeof variant.configuration_reference !== "string") { - throw new TypeError("Variant configuration reference must be a string."); + if (variant.status_override !== undefined && typeof variant.status_override !== "string") { + throw new TypeError("Variant status override must be a string."); } - if (variant.status_override !== undefined && variant.status_override !== "None" && variant.status_override !== "Enabled" && variant.status_override !== "Disabled") { throw new TypeError("Variant status override must be 'None', 'Enabled', or 'Disabled'."); } @@ -119,6 +121,9 @@ function validateUserVariantAllocation(UserAllocations: any) { } for (const allocation of UserAllocations) { + if (typeof allocation !== "object") { + throw new TypeError("User allocation must be an object."); + } if (typeof allocation.variant !== "string") { throw new TypeError("User allocation variant must be a string."); } @@ -139,6 +144,9 @@ function validateGroupVariantAllocation(groupAllocations: any) { } for (const allocation of groupAllocations) { + if (typeof allocation !== "object") { + throw new TypeError("Group allocation must be an object."); + } if (typeof allocation.variant !== "string") { throw new TypeError("Group allocation variant must be a string."); } @@ -159,14 +167,17 @@ function validatePercentileVariantAllocation(percentileAllocations: any) { } for (const allocation of percentileAllocations) { + if (typeof allocation !== "object") { + throw new TypeError("Percentile allocation must be an object."); + } if (typeof allocation.variant !== "string") { throw new TypeError("Percentile allocation variant must be a string."); } - if (typeof allocation.from !== "number") { - throw new TypeError("Percentile allocation from must be a number."); + if (typeof allocation.from !== "number" || allocation.from < 0 || allocation.from > 100) { + throw new TypeError("Percentile allocation from must be a number between 0 and 100."); } - if (typeof allocation.to !== "number") { - throw new TypeError("Percentile allocation to must be a number."); + if (typeof allocation.to !== "number" || allocation.to < 0 || allocation.to > 100) { + throw new TypeError("Percentile allocation to must be a number between 0 and 100."); } } } From 4765b85191e0d67af7092d16e60d63340a48bb6d Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Thu, 24 Oct 2024 14:57:46 +0800 Subject: [PATCH 3/8] update --- src/featureManager.ts | 4 ++-- src/schema/model.ts | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/featureManager.ts b/src/featureManager.ts index 0692265..acd583d 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -3,10 +3,10 @@ import { TimeWindowFilter } from "./filter/TimeWindowFilter.js"; import { IFeatureFilter } from "./filter/FeatureFilter.js"; -import { RequirementType } from "./schema/model.ts"; +import { RequirementType } from "./schema/model.js"; import { IFeatureFlagProvider } from "./featureProvider.js"; import { TargetingFilter } from "./filter/TargetingFilter.js"; -import { validateFeatureFlag } from "./schema/validator.ts"; +import { validateFeatureFlag } from "./schema/validator.js"; export class FeatureManager { #provider: IFeatureFlagProvider; diff --git a/src/schema/model.ts b/src/schema/model.ts index 7ee9cc4..4254da9 100644 --- a/src/schema/model.ts +++ b/src/schema/model.ts @@ -78,10 +78,6 @@ interface Variant { * The configuration value for this feature variant. */ configuration_value?: unknown; - /** - * The path to a configuration section used as the configuration value for this feature variant. - */ - configuration_reference?: string; /** * Overrides the enabled state of the feature if the given variant is assigned. Does not override the state if value is None. */ From 9362aff6efc9754b91db2f65bdeb7b779b5f30e6 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Mon, 28 Oct 2024 13:35:01 +0800 Subject: [PATCH 4/8] update --- src/schema/validator.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/schema/validator.ts b/src/schema/validator.ts index d2aaba2..d7c1d29 100644 --- a/src/schema/validator.ts +++ b/src/schema/validator.ts @@ -73,9 +73,6 @@ function validateVariants(variants: any) { } for (const variant of variants) { - if (typeof variant !== "object") { - throw new TypeError("Variant must be an object."); - } if (typeof variant.name !== "string") { throw new TypeError("Variant name must be a string."); } @@ -121,9 +118,6 @@ function validateUserVariantAllocation(UserAllocations: any) { } for (const allocation of UserAllocations) { - if (typeof allocation !== "object") { - throw new TypeError("User allocation must be an object."); - } if (typeof allocation.variant !== "string") { throw new TypeError("User allocation variant must be a string."); } @@ -144,9 +138,6 @@ function validateGroupVariantAllocation(groupAllocations: any) { } for (const allocation of groupAllocations) { - if (typeof allocation !== "object") { - throw new TypeError("Group allocation must be an object."); - } if (typeof allocation.variant !== "string") { throw new TypeError("Group allocation variant must be a string."); } @@ -167,9 +158,6 @@ function validatePercentileVariantAllocation(percentileAllocations: any) { } for (const allocation of percentileAllocations) { - if (typeof allocation !== "object") { - throw new TypeError("Percentile allocation must be an object."); - } if (typeof allocation.variant !== "string") { throw new TypeError("Percentile allocation variant must be a string."); } From 2353fd6857f3abb6cc4ef8295aab036975dc0a9a Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Mon, 28 Oct 2024 14:15:43 +0800 Subject: [PATCH 5/8] update --- package-lock.json | 2 +- src/schema/validator.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0bc8262..73b3276 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "eslint": "^8.56.0", "mocha": "^10.2.0", "rimraf": "^5.0.5", - "rollup": "^4.9.4", + "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.0", "tslib": "^2.6.2", "typescript": "^5.3.3" diff --git a/src/schema/validator.ts b/src/schema/validator.ts index d7c1d29..c7d6e49 100644 --- a/src/schema/validator.ts +++ b/src/schema/validator.ts @@ -54,9 +54,6 @@ function validateClientFilters(client_filters: any) { } for (const filter of client_filters) { - if (typeof filter.type !== "object") { - throw new TypeError("Client filter must be an object."); - } if (typeof filter.name !== "string") { throw new TypeError("Client filter name must be a string."); } From fe150a8f8a5f97e17105ddc76ed965d8279a7c84 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Wed, 30 Oct 2024 14:55:25 +0800 Subject: [PATCH 6/8] update --- src/schema/model.ts | 8 ----- src/schema/validator.ts | 69 ++++++++++++++++--------------------- test/featureManager.test.ts | 37 ++++++++++++++++++++ test/noFilters.test.ts | 2 +- 4 files changed, 67 insertions(+), 49 deletions(-) diff --git a/src/schema/model.ts b/src/schema/model.ts index 4254da9..c4ff360 100644 --- a/src/schema/model.ts +++ b/src/schema/model.ts @@ -12,14 +12,6 @@ export interface FeatureFlag { * An ID used to uniquely identify and reference the feature. */ id: string; - /** - * A description of the feature. - */ - description?: string; - /** - * A display name for the feature to use for display rather than the ID. - */ - display_name?: string; /** * A feature is OFF if enabled is false. If enabled is true, then the feature is ON if there are no conditions (null or empty) or if the conditions are satisfied. */ diff --git a/src/schema/validator.ts b/src/schema/validator.ts index c7d6e49..833ca0f 100644 --- a/src/schema/validator.ts +++ b/src/schema/validator.ts @@ -13,21 +13,14 @@ export function validateFeatureFlag(featureFlag: any): void { throw new TypeError("Feature flag must be an object."); } if (typeof featureFlag.id !== "string") { - throw new TypeError("Feature flag ID must be a string."); - } - if (featureFlag.description !== undefined && typeof featureFlag.description !== "string") { - throw new TypeError("Feature flag description must be a string."); - } - if (featureFlag.display_name !== undefined && typeof featureFlag.display_name !== "string") { - throw new TypeError("Feature flag display name must be a string."); + throw new TypeError("Feature flag 'id' must be a string."); } if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") { - throw new TypeError("Feature flag enabled must be a boolean."); + throw new TypeError("Feature flag 'enabled' must be a boolean."); } if (featureFlag.conditions !== undefined) { validateFeatureEnablementConditions(featureFlag.conditions); } - // variants, allocation, and telemetry if (featureFlag.variants !== undefined) { validateVariants(featureFlag.variants); } @@ -41,7 +34,7 @@ export function validateFeatureFlag(featureFlag: any): void { function validateFeatureEnablementConditions(conditions: any) { if (conditions.requirement_type !== undefined && conditions.requirement_type !== "Any" && conditions.requirement_type !== "All") { - throw new TypeError("Feature enablement conditions requirement type must be 'Any' or 'All'."); + throw new TypeError("'requirement_type' must be 'Any' or 'All'."); } if (conditions.client_filters !== undefined) { validateClientFilters(conditions.client_filters); @@ -50,50 +43,48 @@ function validateFeatureEnablementConditions(conditions: any) { function validateClientFilters(client_filters: any) { if (!Array.isArray(client_filters)) { - throw new TypeError("Client filter Collection must be an array."); + throw new TypeError("'client_filters' must be an array."); } for (const filter of client_filters) { if (typeof filter.name !== "string") { - throw new TypeError("Client filter name must be a string."); + throw new TypeError("Client filter 'name' must be a string."); } if (filter.parameters !== undefined && typeof filter.parameters !== "object") { - throw new TypeError("Client filter parameters must be an object."); + throw new TypeError("Client filter 'parameters' must be an object."); } - // validate parameters here if we have schema of specific filters in the future } } function validateVariants(variants: any) { if (!Array.isArray(variants)) { - throw new TypeError("Variants must be an array."); + throw new TypeError("'variants' must be an array."); } for (const variant of variants) { if (typeof variant.name !== "string") { - throw new TypeError("Variant name must be a string."); + throw new TypeError("Variant 'name' must be a string."); } // skip configuration_value validation as it accepts any type if (variant.status_override !== undefined && typeof variant.status_override !== "string") { - throw new TypeError("Variant status override must be a string."); + throw new TypeError("Variant 'status_override' must be a string."); } if (variant.status_override !== undefined && variant.status_override !== "None" && variant.status_override !== "Enabled" && variant.status_override !== "Disabled") { - throw new TypeError("Variant status override must be 'None', 'Enabled', or 'Disabled'."); + throw new TypeError("Variant 'status_override' must be 'None', 'Enabled', or 'Disabled'."); } } } -// #region Allocation function validateVariantAllocation(allocation: any) { if (typeof allocation !== "object") { - throw new TypeError("Variant allocation must be an object."); + throw new TypeError("Variant 'allocation' must be an object."); } if (allocation.default_when_disabled !== undefined && typeof allocation.default_when_disabled !== "string") { - throw new TypeError("Variant allocation default_when_disabled must be a string."); + throw new TypeError("Variant allocation 'default_when_disabled' must be a string."); } if (allocation.default_when_enabled !== undefined && typeof allocation.default_when_enabled !== "string") { - throw new TypeError("Variant allocation default_when_enabled must be a string."); + throw new TypeError("Variant allocation 'default_when_enabled' must be a string."); } if (allocation.user !== undefined) { validateUserVariantAllocation(allocation.user); @@ -105,25 +96,25 @@ function validateVariantAllocation(allocation: any) { validatePercentileVariantAllocation(allocation.percentile); } if (allocation.seed !== undefined && typeof allocation.seed !== "string") { - throw new TypeError("Variant allocation seed must be a string."); + throw new TypeError("Variant allocation 'seed' must be a string."); } } function validateUserVariantAllocation(UserAllocations: any) { if (!Array.isArray(UserAllocations)) { - throw new TypeError("User allocations must be an array."); + throw new TypeError("User allocation 'user' must be an array."); } for (const allocation of UserAllocations) { if (typeof allocation.variant !== "string") { - throw new TypeError("User allocation variant must be a string."); + throw new TypeError("User allocation 'variant' must be a string."); } if (!Array.isArray(allocation.users)) { - throw new TypeError("User allocation users must be an array."); + throw new TypeError("User allocation 'users' must be an array."); } for (const user of allocation.users) { if (typeof user !== "string") { - throw new TypeError("Elements in User allocation users must be strings."); + throw new TypeError("Elements in User allocation 'users' must be strings."); } } } @@ -131,19 +122,19 @@ function validateUserVariantAllocation(UserAllocations: any) { function validateGroupVariantAllocation(groupAllocations: any) { if (!Array.isArray(groupAllocations)) { - throw new TypeError("Group allocations must be an array."); + throw new TypeError("Group allocation 'group' must be an array."); } for (const allocation of groupAllocations) { if (typeof allocation.variant !== "string") { - throw new TypeError("Group allocation variant must be a string."); + throw new TypeError("Group allocation 'variant' must be a string."); } if (!Array.isArray(allocation.groups)) { - throw new TypeError("Group allocation groups must be an array."); + throw new TypeError("Group allocation 'groups' must be an array."); } for (const group of allocation.groups) { if (typeof group !== "string") { - throw new TypeError("Elements in Group allocation groups must be strings."); + throw new TypeError("Elements in Group allocation 'groups' must be strings."); } } } @@ -151,18 +142,18 @@ function validateGroupVariantAllocation(groupAllocations: any) { function validatePercentileVariantAllocation(percentileAllocations: any) { if (!Array.isArray(percentileAllocations)) { - throw new TypeError("Percentile allocations must be an array."); + throw new TypeError("Percentile allocation 'percentile' must be an array."); } for (const allocation of percentileAllocations) { if (typeof allocation.variant !== "string") { - throw new TypeError("Percentile allocation variant must be a string."); + throw new TypeError("Percentile allocation 'variant' must be a string."); } if (typeof allocation.from !== "number" || allocation.from < 0 || allocation.from > 100) { - throw new TypeError("Percentile allocation from must be a number between 0 and 100."); + throw new TypeError("Percentile allocation 'from' must be a number between 0 and 100."); } if (typeof allocation.to !== "number" || allocation.to < 0 || allocation.to > 100) { - throw new TypeError("Percentile allocation to must be a number between 0 and 100."); + throw new TypeError("Percentile allocation 'to' must be a number between 0 and 100."); } } } @@ -171,15 +162,13 @@ function validatePercentileVariantAllocation(percentileAllocations: any) { // #region Telemetry function validateTelemetryOptions(telemetry: any) { if (typeof telemetry !== "object") { - throw new TypeError("Telemetry options must be an object."); + throw new TypeError("Telemetry option 'telemetry' must be an object."); } - if (telemetry.enabled !== undefined && typeof telemetry.enabled !== "boolean") { - throw new TypeError("Telemetry enabled must be a boolean."); + throw new TypeError("Telemetry 'enabled' must be a boolean."); } if (telemetry.metadata !== undefined && typeof telemetry.metadata !== "object") { - throw new TypeError("Telemetry metadata must be an object."); + throw new TypeError("Telemetry 'metadata' must be an object."); } - // TODO: validate metadata keys (string) and values (string), not sure if we need to do this } // #endregion diff --git a/test/featureManager.test.ts b/test/featureManager.test.ts index ea9c787..0bfa331 100644 --- a/test/featureManager.test.ts +++ b/test/featureManager.test.ts @@ -72,6 +72,43 @@ describe("feature manager", () => { ]); }); + it("should evaluate features with conditions", () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + { + "id": "Gamma", + "description": "", + "enabled": true, + "conditions": { + "requirement_type": "invalid type", + "client_filters": [ + { "name": "Microsoft.Targeting", "parameters": { "Audience": { "DefaultRolloutPercentage": 50 } } } + ] + } + }, + { + "id": "Delta", + "description": "", + "enabled": true, + "conditions": { + "requirement_type": "Any", + "client_filters": [ + { "name": "Microsoft.Targeting", "parameters": "invalid parameter" } + ] + } + } + ], + }); + + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + return Promise.all([ + expect(featureManager.isEnabled("Gamma")).eventually.rejectedWith("'requirement_type' must be 'Any' or 'All'."), + expect(featureManager.isEnabled("Delta")).eventually.rejectedWith("Client filter 'parameters' must be an object.") + ]); + }); + it("should let the last feature flag win", () => { const jsonObject = { "feature_management": { diff --git a/test/noFilters.test.ts b/test/noFilters.test.ts index 388136c..aaac209 100644 --- a/test/noFilters.test.ts +++ b/test/noFilters.test.ts @@ -61,7 +61,7 @@ describe("feature flags with no filters", () => { return Promise.all([ expect(featureManager.isEnabled("BooleanTrue")).eventually.eq(true), expect(featureManager.isEnabled("BooleanFalse")).eventually.eq(false), - expect(featureManager.isEnabled("InvalidEnabled")).eventually.rejectedWith("Feature flag enabled must be a boolean."), + expect(featureManager.isEnabled("InvalidEnabled")).eventually.rejectedWith("Feature flag 'enabled' must be a boolean."), expect(featureManager.isEnabled("Minimal")).eventually.eq(true), expect(featureManager.isEnabled("NoEnabled")).eventually.eq(false), expect(featureManager.isEnabled("EmptyConditions")).eventually.eq(true) From 54366209410e10e451fa4f8956af763f92b67177 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Wed, 6 Nov 2024 13:24:27 +0800 Subject: [PATCH 7/8] update --- src/featureManager.ts | 2 -- src/featureProvider.ts | 17 +++++++++++++---- src/schema/validator.ts | 28 ++++++++++++++++++++-------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/featureManager.ts b/src/featureManager.ts index acd583d..8cb4008 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -6,7 +6,6 @@ import { IFeatureFilter } from "./filter/FeatureFilter.js"; import { RequirementType } from "./schema/model.js"; import { IFeatureFlagProvider } from "./featureProvider.js"; import { TargetingFilter } from "./filter/TargetingFilter.js"; -import { validateFeatureFlag } from "./schema/validator.js"; export class FeatureManager { #provider: IFeatureFlagProvider; @@ -75,7 +74,6 @@ export class FeatureManager { async #getFeatureFlag(featureName: string): Promise { const featureFlag = await this.#provider.getFeatureFlag(featureName); - validateFeatureFlag(featureFlag); return featureFlag; } diff --git a/src/featureProvider.ts b/src/featureProvider.ts index 897bd11..29f0802 100644 --- a/src/featureProvider.ts +++ b/src/featureProvider.ts @@ -3,6 +3,7 @@ import { IGettable } from "./gettable.js"; import { FeatureFlag, FeatureManagementConfiguration, FEATURE_MANAGEMENT_KEY, FEATURE_FLAGS_KEY } from "./schema/model.js"; +import { validateFeatureFlag } from "./schema/validator.js"; export interface IFeatureFlagProvider { /** @@ -28,12 +29,16 @@ export class ConfigurationMapFeatureFlagProvider implements IFeatureFlagProvider } async getFeatureFlag(featureName: string): Promise { const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY); - return featureConfig?.[FEATURE_FLAGS_KEY]?.findLast((feature) => feature.id === featureName); + const featureFlag = featureConfig?.[FEATURE_FLAGS_KEY]?.findLast((feature) => feature.id === featureName); + validateFeatureFlag(featureFlag); + return featureFlag; } async getFeatureFlags(): Promise { const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY); - return featureConfig?.[FEATURE_FLAGS_KEY] ?? []; + const featureFlag = featureConfig?.[FEATURE_FLAGS_KEY] ?? []; + validateFeatureFlag(featureFlag); + return featureFlag; } } @@ -49,10 +54,14 @@ export class ConfigurationObjectFeatureFlagProvider implements IFeatureFlagProvi async getFeatureFlag(featureName: string): Promise { const featureFlags = this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY]; - return featureFlags?.findLast((feature: FeatureFlag) => feature.id === featureName); + const featureFlag = featureFlags?.findLast((feature: FeatureFlag) => feature.id === featureName); + validateFeatureFlag(featureFlag); + return featureFlag; } async getFeatureFlags(): Promise { - return this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY] ?? []; + const featureFlag = this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY] ?? []; + validateFeatureFlag(featureFlag); + return featureFlag; } } diff --git a/src/schema/validator.ts b/src/schema/validator.ts index 833ca0f..ca4af8c 100644 --- a/src/schema/validator.ts +++ b/src/schema/validator.ts @@ -33,6 +33,9 @@ export function validateFeatureFlag(featureFlag: any): void { } function validateFeatureEnablementConditions(conditions: any) { + if (typeof conditions !== "object") { + throw new TypeError("Feature flag 'conditions' must be an object."); + } if (conditions.requirement_type !== undefined && conditions.requirement_type !== "Any" && conditions.requirement_type !== "All") { throw new TypeError("'requirement_type' must be 'Any' or 'All'."); } @@ -43,7 +46,7 @@ function validateFeatureEnablementConditions(conditions: any) { function validateClientFilters(client_filters: any) { if (!Array.isArray(client_filters)) { - throw new TypeError("'client_filters' must be an array."); + throw new TypeError("Feature flag conditions 'client_filters' must be an array."); } for (const filter of client_filters) { @@ -58,7 +61,7 @@ function validateClientFilters(client_filters: any) { function validateVariants(variants: any) { if (!Array.isArray(variants)) { - throw new TypeError("'variants' must be an array."); + throw new TypeError("Feature flag 'variants' must be an array."); } for (const variant of variants) { @@ -102,10 +105,13 @@ function validateVariantAllocation(allocation: any) { function validateUserVariantAllocation(UserAllocations: any) { if (!Array.isArray(UserAllocations)) { - throw new TypeError("User allocation 'user' must be an array."); + throw new TypeError("Variant 'user' allocation must be an array."); } for (const allocation of UserAllocations) { + if (typeof allocation !== "object") { + throw new TypeError("Elements in 'user' allocation must be an object."); + } if (typeof allocation.variant !== "string") { throw new TypeError("User allocation 'variant' must be a string."); } @@ -114,7 +120,7 @@ function validateUserVariantAllocation(UserAllocations: any) { } for (const user of allocation.users) { if (typeof user !== "string") { - throw new TypeError("Elements in User allocation 'users' must be strings."); + throw new TypeError("Elements in user allocation 'users' must be strings."); } } } @@ -122,10 +128,13 @@ function validateUserVariantAllocation(UserAllocations: any) { function validateGroupVariantAllocation(groupAllocations: any) { if (!Array.isArray(groupAllocations)) { - throw new TypeError("Group allocation 'group' must be an array."); + throw new TypeError("Variant 'group' allocation must be an array."); } for (const allocation of groupAllocations) { + if (typeof allocation !== "object") { + throw new TypeError("Elements in 'group' allocation must be an object."); + } if (typeof allocation.variant !== "string") { throw new TypeError("Group allocation 'variant' must be a string."); } @@ -134,7 +143,7 @@ function validateGroupVariantAllocation(groupAllocations: any) { } for (const group of allocation.groups) { if (typeof group !== "string") { - throw new TypeError("Elements in Group allocation 'groups' must be strings."); + throw new TypeError("Elements in group allocation 'groups' must be strings."); } } } @@ -142,10 +151,13 @@ function validateGroupVariantAllocation(groupAllocations: any) { function validatePercentileVariantAllocation(percentileAllocations: any) { if (!Array.isArray(percentileAllocations)) { - throw new TypeError("Percentile allocation 'percentile' must be an array."); + throw new TypeError("Variant 'percentile' allocation must be an array."); } for (const allocation of percentileAllocations) { + if (typeof allocation !== "object") { + throw new TypeError("Elements in 'percentile' allocation must be an object."); + } if (typeof allocation.variant !== "string") { throw new TypeError("Percentile allocation 'variant' must be a string."); } @@ -162,7 +174,7 @@ function validatePercentileVariantAllocation(percentileAllocations: any) { // #region Telemetry function validateTelemetryOptions(telemetry: any) { if (typeof telemetry !== "object") { - throw new TypeError("Telemetry option 'telemetry' must be an object."); + throw new TypeError("Feature flag 'telemetry' must be an object."); } if (telemetry.enabled !== undefined && typeof telemetry.enabled !== "boolean") { throw new TypeError("Telemetry 'enabled' must be a boolean."); From 7d52beca8e6249d417d86f2d00eceaf80dfdecc0 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Wed, 6 Nov 2024 14:08:15 +0800 Subject: [PATCH 8/8] update --- src/schema/validator.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/schema/validator.ts b/src/schema/validator.ts index ca4af8c..1d63d72 100644 --- a/src/schema/validator.ts +++ b/src/schema/validator.ts @@ -110,7 +110,7 @@ function validateUserVariantAllocation(UserAllocations: any) { for (const allocation of UserAllocations) { if (typeof allocation !== "object") { - throw new TypeError("Elements in 'user' allocation must be an object."); + throw new TypeError("Elements in variant 'user' allocation must be an object."); } if (typeof allocation.variant !== "string") { throw new TypeError("User allocation 'variant' must be a string."); @@ -133,7 +133,7 @@ function validateGroupVariantAllocation(groupAllocations: any) { for (const allocation of groupAllocations) { if (typeof allocation !== "object") { - throw new TypeError("Elements in 'group' allocation must be an object."); + throw new TypeError("Elements in variant 'group' allocation must be an object."); } if (typeof allocation.variant !== "string") { throw new TypeError("Group allocation 'variant' must be a string."); @@ -156,7 +156,7 @@ function validatePercentileVariantAllocation(percentileAllocations: any) { for (const allocation of percentileAllocations) { if (typeof allocation !== "object") { - throw new TypeError("Elements in 'percentile' allocation must be an object."); + throw new TypeError("Elements in variant 'percentile' allocation must be an object."); } if (typeof allocation.variant !== "string") { throw new TypeError("Percentile allocation 'variant' must be a string.");