diff --git a/package-lock.json b/package-lock.json index deb000fc..bcc8ae93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0-preview", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.6.0", + "@azure/app-configuration": "^1.6.1", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0" }, @@ -57,9 +57,9 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.6.0.tgz", - "integrity": "sha512-5Ae4SB0g4VbTnF7B+bwlkRLesRIYcaeg6e2Qxf0RlOEIetIgfAZiX6S5e7hD83X5RkwiPmzDm8rJm6HDpnVcvQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.6.1.tgz", + "integrity": "sha512-pk8zyG/8Nc6VN7uDA9QY19UFhTXneUbnB+5IcW9uuPyVDXU17TcXBI4xY1ZBm7hmhn0yh3CeZK4kOxa/tjsMqQ==", "dependencies": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.3.0", @@ -3608,9 +3608,9 @@ } }, "@azure/app-configuration": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.6.0.tgz", - "integrity": "sha512-5Ae4SB0g4VbTnF7B+bwlkRLesRIYcaeg6e2Qxf0RlOEIetIgfAZiX6S5e7hD83X5RkwiPmzDm8rJm6HDpnVcvQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.6.1.tgz", + "integrity": "sha512-pk8zyG/8Nc6VN7uDA9QY19UFhTXneUbnB+5IcW9uuPyVDXU17TcXBI4xY1ZBm7hmhn0yh3CeZK4kOxa/tjsMqQ==", "requires": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.3.0", diff --git a/package.json b/package.json index 25cdef61..199b16d4 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "uuid": "^9.0.1" }, "dependencies": { - "@azure/app-configuration": "^1.6.0", + "@azure/app-configuration": "^1.6.1", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0" } diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index 1f38d29b..7d8120d3 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -10,7 +10,7 @@ export type AzureAppConfiguration = { refresh(): Promise; /** - * API to register callback listeners, which will be called only when a refresh operation successfully updates key-values. + * API to register callback listeners, which will be called only when a refresh operation successfully updates key-values or feature flags. * * @param listener - Callback function to be registered. * @param thisArg - Optional. Value to use as `this` when executing callback. diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 1ac77904..6c650244 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, isFeatureFlag } from "@azure/app-configuration"; +import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag } from "@azure/app-configuration"; import { RestError } from "@azure/core-rest-pipeline"; import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions"; @@ -9,11 +9,19 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter"; import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions"; import { Disposable } from "./common/disposable"; +import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME } from "./featureManagement/constants"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter"; import { RefreshTimer } from "./refresh/RefreshTimer"; import { getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils"; import { KeyFilter, LabelFilter, SettingSelector } from "./types"; +type PagedSettingSelector = SettingSelector & { + /** + * Key: page eTag, Value: feature flag configurations + */ + pageEtags?: string[]; +}; + export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** * Hosting key-value pairs in the configuration store. @@ -40,6 +48,13 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #sentinels: ConfigurationSettingId[] = []; #refreshTimer: RefreshTimer; + // Feature flags + #featureFlagRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; + #featureFlagRefreshTimer: RefreshTimer; + + // selectors + #featureFlagSelectors: PagedSettingSelector[] = []; + constructor( client: AppConfigurationClient, options: AzureAppConfigurationOptions | undefined @@ -84,13 +99,31 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#refreshTimer = new RefreshTimer(this.#refreshInterval); } - // TODO: should add more adapters to process different type of values - // feature flag, others + // feature flag options + if (options?.featureFlagOptions?.enabled) { + // validate feature flag selectors + this.#featureFlagSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); + + if (options.featureFlagOptions.refresh?.enabled) { + const { refreshIntervalInMs } = options.featureFlagOptions.refresh; + // custom refresh interval + if (refreshIntervalInMs !== undefined) { + if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { + throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); + } else { + this.#featureFlagRefreshInterval = refreshIntervalInMs; + } + } + + this.#featureFlagRefreshTimer = new RefreshTimer(this.#featureFlagRefreshInterval); + } + } + this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); this.#adapters.push(new JsonKeyValueAdapter()); } - // ReadonlyMap APIs + // #region ReadonlyMap APIs get(key: string): T | undefined { return this.#configMap.get(key); } @@ -122,16 +155,33 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { [Symbol.iterator](): IterableIterator<[string, any]> { return this.#configMap[Symbol.iterator](); } + // #endregion get #refreshEnabled(): boolean { return !!this.#options?.refreshOptions?.enabled; } + get #featureFlagEnabled(): boolean { + return !!this.#options?.featureFlagOptions?.enabled; + } + + get #featureFlagRefreshEnabled(): boolean { + return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled; + } + + get #requestTraceOptions() { + return { + requestTracingEnabled: this.#requestTracingEnabled, + initialLoadCompleted: this.#isInitialLoadCompleted, + appConfigOptions: this.#options + }; + } + async #loadSelectedKeyValues(): Promise { const loadedSettings: ConfigurationSetting[] = []; // validate selectors - const selectors = getValidSelectors(this.#options?.selectors); + const selectors = getValidKeyValueSelectors(this.#options?.selectors); for (const selector of selectors) { const listOptions: ListConfigurationSettingsOptions = { @@ -139,13 +189,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { labelFilter: selector.labelFilter }; - const requestTraceOptions = { - requestTracingEnabled: this.#requestTracingEnabled, - initialLoadCompleted: this.#isInitialLoadCompleted, - appConfigOptions: this.#options - }; const settings = listConfigurationSettingsWithTrace( - requestTraceOptions, + this.#requestTraceOptions, this.#client, listOptions ); @@ -186,7 +231,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { async #loadSelectedAndWatchedKeyValues() { const keyValues: [key: string, value: unknown][] = []; - const loadedSettings = await this.#loadSelectedKeyValues(); await this.#updateWatchedKeyValuesEtag(loadedSettings); @@ -196,17 +240,61 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { keyValues.push([key, value]); } - this.#configMap.clear(); // clear existing key-values in case of configuration setting deletion + this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion for (const [k, v] of keyValues) { this.#configMap.set(k, v); } } + async #clearLoadedKeyValues() { + for (const key of this.#configMap.keys()) { + if (key !== FEATURE_MANAGEMENT_KEY_NAME) { + this.#configMap.delete(key); + } + } + } + + async #loadFeatureFlags() { + // Temporary map to store feature flags, key is the key of the setting, value is the raw value of the setting + const featureFlagsMap = new Map(); + for (const selector of this.#featureFlagSelectors) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, + labelFilter: selector.labelFilter + }; + + const pageEtags: string[] = []; + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + this.#client, + listOptions + ).byPage(); + for await (const page of pageIterator) { + pageEtags.push(page.etag ?? ""); + for (const setting of page.items) { + if (isFeatureFlag(setting)) { + featureFlagsMap.set(setting.key, setting.value); + } + } + } + selector.pageEtags = pageEtags; + } + + // parse feature flags + const featureFlags = Array.from(featureFlagsMap.values()).map(rawFlag => JSON.parse(rawFlag)); + + // feature_management is a reserved key, and feature_flags is an array of feature flags + this.#configMap.set(FEATURE_MANAGEMENT_KEY_NAME, { [FEATURE_FLAGS_KEY_NAME]: featureFlags }); + } + /** * Load the configuration store for the first time. */ async load() { await this.#loadSelectedAndWatchedKeyValues(); + if (this.#featureFlagEnabled) { + await this.#loadFeatureFlags(); + } // Mark all settings have loaded at startup. this.#isInitialLoadCompleted = true; } @@ -258,13 +346,46 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Refresh the configuration store. */ async refresh(): Promise { - if (!this.#refreshEnabled) { - throw new Error("Refresh is not enabled."); + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { + throw new Error("Refresh is not enabled for key-values or feature flags."); + } + + const refreshTasks: Promise[] = []; + if (this.#refreshEnabled) { + refreshTasks.push(this.#refreshKeyValues()); + } + if (this.#featureFlagRefreshEnabled) { + refreshTasks.push(this.#refreshFeatureFlags()); + } + + // wait until all tasks are either resolved or rejected + const results = await Promise.allSettled(refreshTasks); + + // check if any refresh task failed + for (const result of results) { + if (result.status === "rejected") { + throw result.reason; + } + } + + // check if any refresh task succeeded + const anyRefreshed = results.some(result => result.status === "fulfilled" && result.value === true); + if (anyRefreshed) { + // successfully refreshed, run callbacks in async + for (const listener of this.#onRefreshListeners) { + listener(); + } } + } + /** + * Refresh key-values. + * @returns true if key-values are refreshed, false otherwise. + */ + async #refreshKeyValues(): Promise { // if still within refresh interval/backoff, return if (!this.#refreshTimer.canRefresh()) { - return Promise.resolve(); + return Promise.resolve(false); } // try refresh if any of watched settings is changed. @@ -282,26 +403,74 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { break; } } + if (needRefresh) { try { await this.#loadSelectedAndWatchedKeyValues(); - this.#refreshTimer.reset(); } catch (error) { // if refresh failed, backoff this.#refreshTimer.backoff(); throw error; } + } - // successfully refreshed, run callbacks in async - for (const listener of this.#onRefreshListeners) { - listener(); + this.#refreshTimer.reset(); + return Promise.resolve(needRefresh); + } + + /** + * Refresh feature flags. + * @returns true if feature flags are refreshed, false otherwise. + */ + async #refreshFeatureFlags(): Promise { + // if still within refresh interval/backoff, return + if (!this.#featureFlagRefreshTimer.canRefresh()) { + return Promise.resolve(false); + } + + // check if any feature flag is changed + let needRefresh = false; + for (const selector of this.#featureFlagSelectors) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, + labelFilter: selector.labelFilter, + pageEtags: selector.pageEtags + }; + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + this.#client, + listOptions + ).byPage(); + + for await (const page of pageIterator) { + if (page._response.status === 200) { // created or changed + needRefresh = true; + break; + } + } + + if (needRefresh) { + break; // short-circuit if result from any of the selectors is changed + } + } + + if (needRefresh) { + try { + await this.#loadFeatureFlags(); + } catch (error) { + // if refresh failed, backoff + this.#featureFlagRefreshTimer.backoff(); + throw error; } } + + this.#featureFlagRefreshTimer.reset(); + return Promise.resolve(needRefresh); } onRefresh(listener: () => any, thisArg?: any): Disposable { - if (!this.#refreshEnabled) { - throw new Error("Refresh is not enabled."); + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { + throw new Error("Refresh is not enabled for key-values or feature flags."); } const boundedListener = listener.bind(thisArg); @@ -348,18 +517,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { let response: GetConfigurationSettingResponse | undefined; try { - const requestTraceOptions = { - requestTracingEnabled: this.#requestTracingEnabled, - initialLoadCompleted: this.#isInitialLoadCompleted, - appConfigOptions: this.#options - }; response = await getConfigurationSettingWithTrace( - requestTraceOptions, + this.#requestTraceOptions, this.#client, configurationSettingId, customOptions ); - } catch (error) { if (error instanceof RestError && error.statusCode === 404) { response = undefined; @@ -371,12 +534,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } -function getValidSelectors(selectors?: SettingSelector[]) { - if (!selectors || selectors.length === 0) { - // Default selector: key: *, label: \0 - return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; - } - +function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { // below code deduplicates selectors by keyFilter and labelFilter, the latter selector wins const uniqueSelectors: SettingSelector[] = []; for (const selector of selectors) { @@ -401,3 +559,20 @@ function getValidSelectors(selectors?: SettingSelector[]) { return selector; }); } + +function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] { + if (!selectors || selectors.length === 0) { + // Default selector: key: *, label: \0 + return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; + } + return getValidSelectors(selectors); +} + +function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] { + if (!selectors || selectors.length === 0) { + // selectors must be explicitly provided. + throw new Error("Feature flag selectors must be provided."); + } else { + return getValidSelectors(selectors); + } +} \ No newline at end of file diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index b4532804..e780fc89 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -5,6 +5,7 @@ import { AppConfigurationClientOptions } from "@azure/app-configuration"; import { KeyVaultOptions } from "./keyvault/KeyVaultOptions"; import { RefreshOptions } from "./RefreshOptions"; import { SettingSelector } from "./types"; +import { FeatureFlagOptions } from "./featureManagement/FeatureFlagOptions"; export const MaxRetries = 2; export const MaxRetryDelayInMs = 60000; @@ -36,8 +37,14 @@ export interface AzureAppConfigurationOptions { * Specifies options used to resolve Vey Vault references. */ keyVaultOptions?: KeyVaultOptions; + /** * Specifies options for dynamic refresh key-values. */ refreshOptions?: RefreshOptions; + + /** + * Specifies options used to configure feature flags. + */ + featureFlagOptions?: FeatureFlagOptions; } \ No newline at end of file diff --git a/src/RefreshOptions.ts b/src/RefreshOptions.ts index 12a7474c..0d7b75c3 100644 --- a/src/RefreshOptions.ts +++ b/src/RefreshOptions.ts @@ -25,3 +25,17 @@ export interface RefreshOptions { */ watchedSettings?: WatchedSetting[]; } + +export interface FeatureFlagRefreshOptions { + /** + * Specifies whether the provider should automatically refresh all feature flags if any feature flag changes. + */ + enabled: boolean; + + /** + * Specifies the minimum time that must elapse before checking the server for any new changes. + * Default value is 30 seconds. Must be greater than 1 second. + * Any refresh operation triggered will not update the value for a key until after the interval. + */ + refreshIntervalInMs?: number; +} diff --git a/src/featureManagement/FeatureFlagOptions.ts b/src/featureManagement/FeatureFlagOptions.ts new file mode 100644 index 00000000..de0ff896 --- /dev/null +++ b/src/featureManagement/FeatureFlagOptions.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { FeatureFlagRefreshOptions } from "../RefreshOptions"; +import { SettingSelector } from "../types"; + +/** + * Options used to configure feature flags. + */ +export interface FeatureFlagOptions { + /** + * Specifies whether feature flags will be loaded from Azure App Configuration. + + */ + enabled: boolean; + + /** + * Specifies the selectors used to filter feature flags. + * + * @remarks + * keyFilter of selector will be prefixed with "appconfig.featureflag/" when request is sent. + * If no selectors are specified then no feature flags will be retrieved. + */ + selectors?: SettingSelector[]; + + /** + * Specifies how feature flag refresh is configured. All selected feature flags will be watched for changes. + */ + refresh?: FeatureFlagRefreshOptions; +} \ No newline at end of file diff --git a/src/featureManagement/constants.ts b/src/featureManagement/constants.ts new file mode 100644 index 00000000..d8a6afc6 --- /dev/null +++ b/src/featureManagement/constants.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const FEATURE_MANAGEMENT_KEY_NAME = "feature_management"; +export const FEATURE_FLAGS_KEY_NAME = "feature_flags"; \ No newline at end of file diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts new file mode 100644 index 00000000..22db36af --- /dev/null +++ b/test/featureFlag.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +import { load } from "./exportedApi"; +import { createMockedConnectionString, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +const sampleVariantValue = JSON.stringify({ + "id": "variant", + "description": "", + "enabled": true, + "variants": [ + { + "name": "Off", + "configuration_value": false + }, + { + "name": "On", + "configuration_value": true + } + ], + "allocation": { + "percentile": [ + { + "variant": "Off", + "from": 0, + "to": 40 + }, + { + "variant": "On", + "from": 49, + "to": 100 + } + ], + "default_when_enabled": "Off", + "default_when_disabled": "Off" + }, + "telemetry": { + "enabled": false + } +}); + +const mockedKVs = [{ + key: "app.settings.fontColor", + value: "red", +}, { + key: ".appconfig.featureflag/variant", + value: sampleVariantValue, + contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8", +}].map(createMockedKeyValue).concat([ + createMockedFeatureFlag("Beta", { enabled: true }), + createMockedFeatureFlag("Alpha_1", { enabled: true }), + createMockedFeatureFlag("Alpha_2", { enabled: false }), +]); + +describe("feature flags", function () { + this.timeout(10000); + + before(() => { + mockAppConfigurationClientListConfigurationSettings(mockedKVs); + }); + + after(() => { + restoreMocks(); + }) + it("should load feature flags if enabled", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*" + }] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + expect(settings.get("feature_management").feature_flags).not.undefined; + }); + + it("should not load feature flags if disabled", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: false + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).undefined; + }); + + it("should not load feature flags if featureFlagOptions not specified", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + expect(settings).not.undefined; + expect(settings.get("feature_management")).undefined; + }); + + it("should throw error if selectors not specified", async () => { + const connectionString = createMockedConnectionString(); + return expect(load(connectionString, { + featureFlagOptions: { + enabled: true + } + })).eventually.rejectedWith("Feature flag selectors must be provided."); + }); + + it("should load feature flags with custom selector", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "Alpha*" + }] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(2); + }); + + it("should parse variant", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "variant" + }] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + const variant = featureFlags[0]; + expect(variant).not.undefined; + expect(variant.id).equals("variant"); + expect(variant.variants).not.undefined; + expect(variant.variants.length).equals(2); + expect(variant.variants[0].configuration_value).equals(false); + expect(variant.variants[1].configuration_value).equals(true); + expect(variant.allocation).not.undefined; + expect(variant.allocation.percentile).not.undefined; + expect(variant.allocation.percentile.length).equals(2); + expect(variant.allocation.percentile[0].variant).equals("Off"); + expect(variant.allocation.percentile[1].variant).equals("On"); + expect(variant.allocation.default_when_enabled).equals("Off"); + expect(variant.allocation.default_when_disabled).equals("Off"); + expect(variant.telemetry).not.undefined; + }); + +}); diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 2378856f..b756038a 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi"; -import { mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, restoreMocks, createMockedConnectionString, createMockedKeyValue, sleepInMs } from "./utils/testHelper"; +import { mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, restoreMocks, createMockedConnectionString, createMockedKeyValue, sleepInMs, createMockedFeatureFlag } from "./utils/testHelper"; import * as uuid from "uuid"; let mockedKVs: any[] = []; @@ -18,6 +18,7 @@ function updateSetting(key: string, value: any) { setting.etag = uuid.v4(); } } + function addSetting(key: string, value: any) { mockedKVs.push(createMockedKeyValue({ key, value })); } @@ -43,7 +44,7 @@ describe("dynamic refresh", function () { const connectionString = createMockedConnectionString(); const settings = await load(connectionString); const refreshCall = settings.refresh(); - return expect(refreshCall).eventually.rejectedWith("Refresh is not enabled."); + return expect(refreshCall).eventually.rejectedWith("Refresh is not enabled for key-values or feature flags."); }); it("should only allow non-empty list of watched settings when refresh is enabled", async () => { @@ -124,10 +125,10 @@ describe("dynamic refresh", function () { it("should throw error when calling onRefresh when refresh is not enabled", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString); - expect(() => settings.onRefresh(() => { })).throws("Refresh is not enabled."); + expect(() => settings.onRefresh(() => { })).throws("Refresh is not enabled for key-values or feature flags."); }); - it("should only udpate values after refreshInterval", async () => { + it("should only update values after refreshInterval", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { refreshOptions: { @@ -319,4 +320,107 @@ describe("dynamic refresh", function () { // should not refresh expect(settings.get("app.settings.fontColor")).eq("red"); }); + +}); + +describe("dynamic refresh feature flags", function () { + this.timeout(10000); + + beforeEach(() => { + }); + + afterEach(() => { + restoreMocks(); + }) + + it("should refresh feature flags when enabled", async () => { + mockedKVs = [ + createMockedFeatureFlag("Beta", { enabled: true }) + ]; + mockAppConfigurationClientListConfigurationSettings(mockedKVs); + mockAppConfigurationClientGetConfigurationSetting(mockedKVs) + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "Beta" + }], + refresh: { + enabled: true, + refreshIntervalInMs: 2000 // 2 seconds for quick test. + } + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + expect(settings.get("feature_management").feature_flags).not.undefined; + expect(settings.get("feature_management").feature_flags[0].id).eq("Beta"); + expect(settings.get("feature_management").feature_flags[0].enabled).eq(true); + + // change feature flag Beta to false + updateSetting(".appconfig.featureflag/Beta", JSON.stringify({ + "id": "Beta", + "description": "", + "enabled": false, + "conditions": { + "client_filters": [] + } + })); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + + expect(settings.get("feature_management").feature_flags[0].id).eq("Beta"); + expect(settings.get("feature_management").feature_flags[0].enabled).eq(false); + + }); + + it("should refresh feature flags only on change, based on page etags", async () => { + // mock multiple pages of feature flags + const page1 = [ + createMockedFeatureFlag("Alpha_1", { enabled: true }), + createMockedFeatureFlag("Alpha_2", { enabled: true }), + ]; + const page2 = [ + createMockedFeatureFlag("Beta_1", { enabled: true }), + createMockedFeatureFlag("Beta_2", { enabled: true }), + ]; + mockAppConfigurationClientListConfigurationSettings(page1, page2); + mockAppConfigurationClientGetConfigurationSetting([...page1, ...page2]); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*" + }], + refresh: { + enabled: true, + refreshIntervalInMs: 2000 // 2 seconds for quick test. + } + } + }); + + let refreshSuccessfulCount = 0; + settings.onRefresh(() => { + refreshSuccessfulCount++; + }); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(refreshSuccessfulCount).eq(0); // no change in feature flags, because page etags are the same. + + // change feature flag Beta_1 to false + page2[0] = createMockedFeatureFlag("Beta_1", { enabled: false }); + restoreMocks(); + mockAppConfigurationClientListConfigurationSettings(page1, page2); + mockAppConfigurationClientGetConfigurationSetting([...page1, ...page2]); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(refreshSuccessfulCount).eq(1); // change in feature flags, because page etags are different. + }); }); \ No newline at end of file diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index d4297823..80da36bc 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -9,34 +9,88 @@ import * as uuid from "uuid"; import { RestError } from "@azure/core-rest-pipeline"; import { promisify } from "util"; const sleepInMs = promisify(setTimeout); +import * as crypto from "crypto"; const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000"; const TEST_CLIENT_SECRET = "0000000000000000000000000000000000000000"; -function mockAppConfigurationClientListConfigurationSettings(kvList: ConfigurationSetting[]) { - function* testKvSetGenerator(kvs: any[]) { - yield* kvs; - } +function _sha256(input) { + return crypto.createHash("sha256").update(input).digest("hex"); +} + +function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { + const keyFilter = listOptions?.keyFilter ?? "*"; + const labelFilter = listOptions?.labelFilter ?? "*"; + return unfilteredKvs.filter(kv => { + const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter; + let labelMatched = false; + if (labelFilter === "*") { + labelMatched = true; + } else if (labelFilter === "\0") { + labelMatched = kv.label === undefined; + } else if (labelFilter.endsWith("*")) { + labelMatched = kv.label !== undefined && kv.label.startsWith(labelFilter.slice(0, -1)); + } else { + labelMatched = kv.label === labelFilter; + } + return keyMatched && labelMatched; + }) +} + +/** + * Mocks the listConfigurationSettings method of AppConfigurationClient to return the provided pages of ConfigurationSetting. + * E.g. + * - mockAppConfigurationClientListConfigurationSettings([item1, item2, item3]) // single page + * - mockAppConfigurationClientListConfigurationSettings([item1, item2], [item3], [item4]) // multiple pages + * + * @param pages List of pages, each page is a list of ConfigurationSetting + */ +function mockAppConfigurationClientListConfigurationSettings(...pages: ConfigurationSetting[][]) { + sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake((listOptions) => { - const keyFilter = listOptions?.keyFilter ?? "*"; - const labelFilter = listOptions?.labelFilter ?? "*"; - const kvs = kvList.filter(kv => { - const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter; - - let labelMatched = false; - if (labelFilter === "*") { - labelMatched = true; - } else if (labelFilter === "\0") { - labelMatched = kv.label === undefined; - } else if (labelFilter.endsWith("*")) { - labelMatched = kv.label !== undefined && kv.label.startsWith(labelFilter.slice(0, -1)); - } else { - labelMatched = kv.label === labelFilter; + let kvs = _filterKVs(pages.flat(), listOptions); + const mockIterator: AsyncIterableIterator & { byPage(): AsyncIterableIterator } = { + [Symbol.asyncIterator](): AsyncIterableIterator { + kvs = _filterKVs(pages.flat(), listOptions); + return this; + }, + next() { + const value = kvs.shift(); + return Promise.resolve({ done: !value, value }); + }, + byPage(): AsyncIterableIterator { + let remainingPages; + const pageEtags = listOptions?.pageEtags ? [...listOptions.pageEtags] : undefined; // a copy of the original list + return { + [Symbol.asyncIterator](): AsyncIterableIterator { + remainingPages = [...pages]; + return this; + }, + next() { + const pageItems = remainingPages.shift(); + const pageEtag = pageEtags?.shift(); + if (pageItems === undefined) { + return Promise.resolve({ done: true, value: undefined }); + } else { + const items = _filterKVs(pageItems ?? [], listOptions); + const etag = _sha256(JSON.stringify(items)); + const statusCode = pageEtag === etag ? 304 : 200; + return Promise.resolve({ + done: false, + value: { + items, + etag, + _response: { status: statusCode } + } + }); + } + } + } } - return keyMatched && labelMatched; - }) - return testKvSetGenerator(kvs) as any; + }; + + return mockIterator as any; }); } @@ -113,7 +167,7 @@ const createMockedJsonKeyValue = (key: string, value: any): ConfigurationSetting isReadOnly: false }); -const createMockedKeyValue = (props: {[key: string]: any}): ConfigurationSetting => (Object.assign({ +const createMockedKeyValue = (props: { [key: string]: any }): ConfigurationSetting => (Object.assign({ value: "TestValue", key: "TestKey", contentType: "", @@ -123,6 +177,23 @@ const createMockedKeyValue = (props: {[key: string]: any}): ConfigurationSetting isReadOnly: false }, props)); +const createMockedFeatureFlag = (name: string, flagProps?: any, props?: any) => (Object.assign({ + key: `.appconfig.featureflag/${name}`, + value: JSON.stringify(Object.assign({ + "id": name, + "description": "", + "enabled": true, + "conditions": { + "client_filters": [] + } + }, flagProps)), + contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8", + lastModified: new Date(), + tags: {}, + etag: uuid.v4(), + isReadOnly: false +}, props)); + export { sinon, mockAppConfigurationClientListConfigurationSettings, @@ -136,6 +207,7 @@ export { createMockedKeyVaultReference, createMockedJsonKeyValue, createMockedKeyValue, + createMockedFeatureFlag, sleepInMs } \ No newline at end of file