diff --git a/examples/refresh.mjs b/examples/refresh.mjs new file mode 100644 index 0000000..12231fc --- /dev/null +++ b/examples/refresh.mjs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as dotenv from "dotenv"; +import { promisify } from "util"; +dotenv.config(); +const sleepInMs = promisify(setTimeout); + +/** + * This example retrives all settings with key following pattern "app.settings.*", i.e. starting with "app.settings.". + * With the option `trimKeyPrefixes`, it trims the prefix "app.settings." from keys for simplicity. + * Value of config "app.settings.message" will be printed. + * It also watches for changes to the key "app.settings.sentinel" and refreshes the configuration when it changes. + * + * Below environment variables are required for this example: + * - APPCONFIG_CONNECTION_STRING + */ + +import { load } from "@azure/app-configuration-provider"; +const connectionString = process.env.APPCONFIG_CONNECTION_STRING; +const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app.settings.*" + }], + trimKeyPrefixes: ["app.settings."], + refreshOptions: { + watchedSettings: [{ key: "app.settings.sentinel" }], + refreshIntervalInMs: 10 * 1000 // Default value is 30 seconds, shorted for this sample + } +}); + +console.log("Using Azure portal or CLI, update the `app.settings.message` value, and then update the `app.settings.sentinel` value in your App Configuration store.") + +// eslint-disable-next-line no-constant-condition +while (true) { + // Refreshing the configuration setting + await settings.refresh(); + + // Current value of message + console.log(settings.get("message")); + + // Waiting before the next refresh + await sleepInMs(5000); +} \ No newline at end of file diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index a4af874..4a90aaf 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -1,6 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { Disposable } from "./common/disposable"; + export type AzureAppConfiguration = { - // methods for advanced features, e.g. refresh() + /** + * API to trigger refresh operation. + */ + refresh(): Promise; + + /** + * API to register callback listeners, which will be called only when a refresh operation successfully updates key-values. + * + * @param listener - Callback funtion to be registered. + * @param thisArg - Optional. Value to use as `this` when executing callback. + */ + onRefresh(listener: () => any, thisArg?: any): Disposable; } & ReadonlyMap; diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 9f6eb83..0b808a5 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,18 +1,21 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, ConfigurationSetting, ListConfigurationSettingsOptions } from "@azure/app-configuration"; +import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions } from "@azure/app-configuration"; +import { RestError } from "@azure/core-rest-pipeline"; import { AzureAppConfiguration } from "./AzureAppConfiguration"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions"; import { IKeyValueAdapter } from "./IKeyValueAdapter"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter"; -import { KeyFilter, LabelFilter } from "./types"; +import { DefaultRefreshIntervalInMs, MinimumRefreshIntervalInMs } from "./RefreshOptions"; +import { Disposable } from "./common/disposable"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter"; +import { RefreshTimer } from "./refresh/RefreshTimer"; import { CorrelationContextHeaderName } from "./requestTracing/constants"; import { createCorrelationContextHeader, requestTracingEnabled } from "./requestTracing/utils"; -import { SettingSelector } from "./types"; +import { KeyFilter, LabelFilter, SettingSelector } from "./types"; -export class AzureAppConfigurationImpl extends Map implements AzureAppConfiguration { +export class AzureAppConfigurationImpl extends Map implements AzureAppConfiguration { #adapters: IKeyValueAdapter[] = []; /** * Trim key prefixes sorted in descending order. @@ -20,9 +23,18 @@ export class AzureAppConfigurationImpl extends Map implements A */ #sortedTrimKeyPrefixes: string[] | undefined; readonly #requestTracingEnabled: boolean; - #correlationContextHeader: string | undefined; #client: AppConfigurationClient; #options: AzureAppConfigurationOptions | undefined; + #isInitialLoadCompleted: boolean = false; + + // Refresh + #refreshInterval: number = DefaultRefreshIntervalInMs; + #onRefreshListeners: Array<() => any> = []; + /** + * Aka watched settings. + */ + #sentinels: ConfigurationSettingId[] = []; + #refreshTimer: RefreshTimer; constructor( client: AppConfigurationClient, @@ -34,21 +46,54 @@ export class AzureAppConfigurationImpl extends Map implements A // Enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); - if (this.#requestTracingEnabled) { - this.#enableRequestTracing(); - } if (options?.trimKeyPrefixes) { this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); } + + if (options?.refreshOptions?.enabled) { + const { watchedSettings, refreshIntervalInMs } = options.refreshOptions; + // validate watched settings + if (watchedSettings === undefined || watchedSettings.length === 0) { + throw new Error("Refresh is enabled but no watched settings are specified."); + } + + // custom refresh interval + if (refreshIntervalInMs !== undefined) { + if (refreshIntervalInMs < MinimumRefreshIntervalInMs) { + throw new Error(`The refresh interval cannot be less than ${MinimumRefreshIntervalInMs} milliseconds.`); + + } else { + this.#refreshInterval = refreshIntervalInMs; + } + } + + for (const setting of watchedSettings) { + if (setting.key.includes("*") || setting.key.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in key of watched settings."); + } + if (setting.label?.includes("*") || setting.label?.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in label of watched settings."); + } + this.#sentinels.push(setting); + } + + this.#refreshTimer = new RefreshTimer(this.#refreshInterval); + } + // TODO: should add more adapters to process different type of values // feature flag, others this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); this.#adapters.push(new JsonKeyValueAdapter()); } - async load() { - const keyValues: [key: string, value: unknown][] = []; + + get #refreshEnabled(): boolean { + return !!this.#options?.refreshOptions?.enabled; + } + + async #loadSelectedKeyValues(): Promise { + const loadedSettings: ConfigurationSetting[] = []; // validate selectors const selectors = getValidSelectors(this.#options?.selectors); @@ -60,25 +105,142 @@ export class AzureAppConfigurationImpl extends Map implements A }; if (this.#requestTracingEnabled) { listOptions.requestOptions = { - customHeaders: this.#customHeaders() + customHeaders: { + [CorrelationContextHeaderName]: createCorrelationContextHeader(this.#options, this.#isInitialLoadCompleted) + } } } const settings = this.#client.listConfigurationSettings(listOptions); for await (const setting of settings) { - if (setting.key) { - const [key, value] = await this.#processAdapters(setting); - const trimmedKey = this.#keyWithPrefixesTrimmed(key); - keyValues.push([trimmedKey, value]); + loadedSettings.push(setting); + } + } + return loadedSettings; + } + + /** + * Update etag of watched settings from loaded data. If a watched setting is not covered by any selector, a request will be sent to retrieve it. + */ + async #updateWatchedKeyValuesEtag(existingSettings: ConfigurationSetting[]): Promise { + if (!this.#refreshEnabled) { + return; + } + + for (const sentinel of this.#sentinels) { + const matchedSetting = existingSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); + if (matchedSetting) { + sentinel.etag = matchedSetting.etag; + } else { + // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing + const { key, label } = sentinel; + const response = await this.#getConfigurationSettingWithTrace({ key, label }); + if (response) { + sentinel.etag = response.etag; + } else { + sentinel.etag = undefined; } } } + } + + async #loadSelectedAndWatchedKeyValues() { + const keyValues: [key: string, value: unknown][] = []; + + const loadedSettings = await this.#loadSelectedKeyValues(); + await this.#updateWatchedKeyValuesEtag(loadedSettings); + + // process key-values, watched settings have higher priority + for (const setting of loadedSettings) { + const [key, value] = await this.#processKeyValues(setting); + keyValues.push([key, value]); + } + + this.clear(); // clear existing key-values in case of configuration setting deletion for (const [k, v] of keyValues) { this.set(k, v); } } + /** + * Load the configuration store for the first time. + */ + async load() { + await this.#loadSelectedAndWatchedKeyValues(); + + // Mark all settings have loaded at startup. + this.#isInitialLoadCompleted = true; + } + + /** + * Refresh the configuration store. + */ + public async refresh(): Promise { + if (!this.#refreshEnabled) { + throw new Error("Refresh is not enabled."); + } + + // if still within refresh interval/backoff, return + if (!this.#refreshTimer.canRefresh()) { + return Promise.resolve(); + } + + // try refresh if any of watched settings is changed. + let needRefresh = false; + for (const sentinel of this.#sentinels.values()) { + const response = await this.#getConfigurationSettingWithTrace(sentinel, { + onlyIfChanged: true + }); + + if (response?.statusCode === 200 // created or changed + || (response === undefined && sentinel.etag !== undefined) // deleted + ) { + sentinel.etag = response?.etag;// update etag of the sentinel + needRefresh = true; + 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(); + } + } + } + + onRefresh(listener: () => any, thisArg?: any): Disposable { + if (!this.#refreshEnabled) { + throw new Error("Refresh is not enabled."); + } + + const boundedListener = listener.bind(thisArg); + this.#onRefreshListeners.push(boundedListener); + + const remove = () => { + const index = this.#onRefreshListeners.indexOf(boundedListener); + if (index >= 0) { + this.#onRefreshListeners.splice(index, 1); + } + } + return new Disposable(remove); + } + + async #processKeyValues(setting: ConfigurationSetting): Promise<[string, unknown]> { + const [key, value] = await this.#processAdapters(setting); + const trimmedKey = this.#keyWithPrefixesTrimmed(key); + return [trimmedKey, value]; + } + async #processAdapters(setting: ConfigurationSetting): Promise<[string, unknown]> { for (const adapter of this.#adapters) { if (adapter.canProcess(setting)) { @@ -99,18 +261,26 @@ export class AzureAppConfigurationImpl extends Map implements A return key; } - #enableRequestTracing() { - this.#correlationContextHeader = createCorrelationContextHeader(this.#options); - } - - #customHeaders() { - if (!this.#requestTracingEnabled) { - return undefined; + async #getConfigurationSettingWithTrace(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { + let response: GetConfigurationSettingResponse | undefined; + try { + const options = { ...customOptions ?? {} }; + if (this.#requestTracingEnabled) { + options.requestOptions = { + customHeaders: { + [CorrelationContextHeaderName]: createCorrelationContextHeader(this.#options, this.#isInitialLoadCompleted) + } + } + } + response = await this.#client.getConfigurationSetting(configurationSettingId, options); + } catch (error) { + if (error instanceof RestError && error.statusCode === 404) { + response = undefined; + } else { + throw error; + } } - - const headers = {}; - headers[CorrelationContextHeaderName] = this.#correlationContextHeader; - return headers; + return response; } } @@ -143,4 +313,4 @@ function getValidSelectors(selectors?: SettingSelector[]) { } return selector; }); -} \ No newline at end of file +} diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 978338d..b453280 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -3,6 +3,7 @@ import { AppConfigurationClientOptions } from "@azure/app-configuration"; import { KeyVaultOptions } from "./keyvault/KeyVaultOptions"; +import { RefreshOptions } from "./RefreshOptions"; import { SettingSelector } from "./types"; export const MaxRetries = 2; @@ -35,4 +36,8 @@ export interface AzureAppConfigurationOptions { * Specifies options used to resolve Vey Vault references. */ keyVaultOptions?: KeyVaultOptions; + /** + * Specifies options for dynamic refresh key-values. + */ + refreshOptions?: RefreshOptions; } \ No newline at end of file diff --git a/src/RefreshOptions.ts b/src/RefreshOptions.ts new file mode 100644 index 0000000..28a0d03 --- /dev/null +++ b/src/RefreshOptions.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WatchedSetting } from "./WatchedSetting"; + +export const DefaultRefreshIntervalInMs = 30 * 1000; +export const MinimumRefreshIntervalInMs = 1 * 1000; + +export interface RefreshOptions { + /** + * Specifies whether the provider should automatically refresh when the configuration is changed. + */ + 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; + + /** + * One or more configuration settings to be watched for changes on the server. + * Any modifications to watched settings will refresh all settings loaded by the configuration provider. + */ + watchedSettings?: WatchedSetting[]; +} \ No newline at end of file diff --git a/src/WatchedSetting.ts b/src/WatchedSetting.ts new file mode 100644 index 0000000..b714f2e --- /dev/null +++ b/src/WatchedSetting.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Fields that uniquely identify a watched configuration setting. + */ +export interface WatchedSetting { + /** + * The key for this setting. + */ + key: string; + + /** + * The label for this setting. + * Leaving this undefined means this setting does not have a label. + */ + label?: string; +} \ No newline at end of file diff --git a/src/common/disposable.ts b/src/common/disposable.ts new file mode 100644 index 0000000..8896013 --- /dev/null +++ b/src/common/disposable.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export class Disposable { + private disposed = false; + constructor(private callOnDispose: () => any) { } + + dispose() { + if (!this.disposed) { + this.callOnDispose(); + } + this.disposed = true; + } + +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 524dbec..dd24604 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export { load } from "./load"; export { AzureAppConfiguration } from "./AzureAppConfiguration"; -export { KeyFilter, LabelFilter } from "./types"; \ No newline at end of file +export { Disposable } from "./common/disposable"; +export { load } from "./load"; +export { KeyFilter, LabelFilter } from "./types"; diff --git a/src/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts new file mode 100644 index 0000000..ac26f31 --- /dev/null +++ b/src/refresh/RefreshTimer.ts @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * The backoff time is between the minimum and maximum backoff time, based on the number of attempts. + * An exponential backoff strategy is used, with a jitter factor to prevent clients from retrying at the same time. + * + * The backoff time is calculated as follows: + * - `basic backoff time` = `MinimumBackoffInMs` * 2 ^ `attempts`, and it is no larger than the `MaximumBackoffInMs`. + * - based on jitter ratio, the jittered time is between [-1, 1) * `JitterRatio` * basic backoff time. + * - the final backoff time is the basic backoff time plus the jittered time. + * + * Note: the backoff time usually is no larger than the refresh interval, which is specified by the user. + * - If the interval is less than the minimum backoff, the interval is used. + * - If the interval is between the minimum and maximum backoff, the interval is used as the maximum backoff. + * - Because of the jitter, the maximum backoff time is actually `MaximumBackoffInMs` * (1 + `JitterRatio`). + */ + +const MinimumBackoffInMs = 30 * 1000; // 30s +const MaximumBackoffInMs = 10 * 60 * 1000; // 10min +const MaxSafeExponential = 30; // Used to avoid overflow. bitwise operations in JavaScript are limited to 32 bits. It overflows at 2^31 - 1. +const JitterRatio = 0.25; + +export class RefreshTimer { + private _minBackoff: number = MinimumBackoffInMs; + private _maxBackoff: number = MaximumBackoffInMs; + private _failedAttempts: number = 0; + private _backoffEnd: number; // Timestamp + constructor( + private _interval: number + ) { + if (this._interval <= 0) { + throw new Error(`Refresh interval must be greater than 0. Given: ${this._interval}`); + } + + this._backoffEnd = Date.now() + this._interval; + } + + public canRefresh(): boolean { + return Date.now() >= this._backoffEnd; + } + + public backoff(): void { + this._failedAttempts += 1; + this._backoffEnd = Date.now() + this._calculateBackoffTime(); + } + + public reset(): void { + this._failedAttempts = 0; + this._backoffEnd = Date.now() + this._interval; + } + + private _calculateBackoffTime(): number { + let minBackoffMs: number; + let maxBackoffMs: number; + if (this._interval <= this._minBackoff) { + return this._interval; + } + + // _minBackoff <= _interval + if (this._interval <= this._maxBackoff) { + minBackoffMs = this._minBackoff; + maxBackoffMs = this._interval; + } else { + minBackoffMs = this._minBackoff; + maxBackoffMs = this._maxBackoff; + } + + // exponential: minBackoffMs * 2^(failedAttempts-1) + const exponential = Math.min(this._failedAttempts - 1, MaxSafeExponential); + let calculatedBackoffMs = minBackoffMs * (1 << exponential); + if (calculatedBackoffMs > maxBackoffMs) { + calculatedBackoffMs = maxBackoffMs; + } + + // jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs + const jitter = JitterRatio * (Math.random() * 2 - 1); + + return calculatedBackoffMs * (1 + jitter); + } + +} diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index 95483bf..8966a30 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -21,7 +21,7 @@ import { } from "./constants"; // Utils -export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined): string { +export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, isInitialLoadCompleted: boolean): string { /* RequestType: 'Startup' during application starting up, 'Watch' after startup completed. Host: identify with defined envs @@ -29,7 +29,7 @@ export function createCorrelationContextHeader(options: AzureAppConfigurationOpt UsersKeyVault */ const keyValues = new Map(); - keyValues.set(RequestTypeKey, RequestType.Startup); // TODO: now always "Startup", until refresh is supported. + keyValues.set(RequestTypeKey, isInitialLoadCompleted ? RequestType.Watch : RequestType.Startup); keyValues.set(HostTypeKey, getHostType()); keyValues.set(EnvironmentKey, isDevEnvironment() ? DevEnvironmentValue : undefined); diff --git a/test/refresh.test.ts b/test/refresh.test.ts new file mode 100644 index 0000000..2378856 --- /dev/null +++ b/test/refresh.test.ts @@ -0,0 +1,322 @@ +// 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 { load } from "./exportedApi"; +import { mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, restoreMocks, createMockedConnectionString, createMockedKeyValue, sleepInMs } from "./utils/testHelper"; +import * as uuid from "uuid"; + +let mockedKVs: any[] = []; + +function updateSetting(key: string, value: any) { + const setting = mockedKVs.find(elem => elem.key === key); + if (setting) { + setting.value = value; + setting.etag = uuid.v4(); + } +} +function addSetting(key: string, value: any) { + mockedKVs.push(createMockedKeyValue({ key, value })); +} + +describe("dynamic refresh", function () { + this.timeout(10000); + + beforeEach(() => { + mockedKVs = [ + { value: "red", key: "app.settings.fontColor" }, + { value: "40", key: "app.settings.fontSize" }, + { value: "30", key: "app.settings.fontSize", label: "prod" } + ].map(createMockedKeyValue); + mockAppConfigurationClientListConfigurationSettings(mockedKVs); + mockAppConfigurationClientGetConfigurationSetting(mockedKVs) + }); + + afterEach(() => { + restoreMocks(); + }) + + it("should throw error when refresh is not enabled but refresh is called", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + const refreshCall = settings.refresh(); + return expect(refreshCall).eventually.rejectedWith("Refresh is not enabled."); + }); + + it("should only allow non-empty list of watched settings when refresh is enabled", async () => { + const connectionString = createMockedConnectionString(); + const loadWithEmptyWatchedSettings = load(connectionString, { + refreshOptions: { + enabled: true, + watchedSettings: [] + } + }); + const loadWithUndefinedWatchedSettings = load(connectionString, { + refreshOptions: { + enabled: true + } + }); + return Promise.all([ + expect(loadWithEmptyWatchedSettings).eventually.rejectedWith("Refresh is enabled but no watched settings are specified."), + expect(loadWithUndefinedWatchedSettings).eventually.rejectedWith("Refresh is enabled but no watched settings are specified.") + ]); + }); + + it("should not allow refresh interval less than 1 second", async () => { + const connectionString = createMockedConnectionString(); + const loadWithInvalidRefreshInterval = load(connectionString, { + refreshOptions: { + enabled: true, + watchedSettings: [ + { key: "app.settings.fontColor" } + ], + refreshIntervalInMs: 999 + } + }); + return expect(loadWithInvalidRefreshInterval).eventually.rejectedWith("The refresh interval cannot be less than 1000 milliseconds."); + }); + + it("should not allow '*' in key or label", async () => { + const connectionString = createMockedConnectionString(); + const loadWithInvalidKey = load(connectionString, { + refreshOptions: { + enabled: true, + watchedSettings: [ + { key: "app.settings.*" } + ] + } + }); + const loadWithInvalidKey2 = load(connectionString, { + refreshOptions: { + enabled: true, + watchedSettings: [ + { key: "keyA,KeyB" } + ] + } + }); + const loadWithInvalidLabel = load(connectionString, { + refreshOptions: { + enabled: true, + watchedSettings: [ + { key: "app.settings.fontColor", label: "*" } + ] + } + }); + const loadWithInvalidLabel2 = load(connectionString, { + refreshOptions: { + enabled: true, + watchedSettings: [ + { key: "app.settings.fontColor", label: "labelA,labelB" } + ] + } + }); + return Promise.all([ + expect(loadWithInvalidKey).eventually.rejectedWith("The characters '*' and ',' are not supported in key of watched settings."), + expect(loadWithInvalidKey2).eventually.rejectedWith("The characters '*' and ',' are not supported in key of watched settings."), + expect(loadWithInvalidLabel).eventually.rejectedWith("The characters '*' and ',' are not supported in label of watched settings."), + expect(loadWithInvalidLabel2).eventually.rejectedWith("The characters '*' and ',' are not supported in label of watched settings.") + ]); + }); + + 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."); + }); + + it("should only udpate values after refreshInterval", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + // change setting + updateSetting("app.settings.fontColor", "blue"); + + // within refreshInterval, should not really refresh + await settings.refresh(); + expect(settings.get("app.settings.fontColor")).eq("red"); + + // after refreshInterval, should really refresh + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); + + it("should update values when watched setting is deleted", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + // delete setting 'app.settings.fontColor' + const newMockedKVs = mockedKVs.filter(elem => elem.key !== "app.settings.fontColor"); + restoreMocks(); + mockAppConfigurationClientListConfigurationSettings(newMockedKVs); + mockAppConfigurationClientGetConfigurationSetting(newMockedKVs); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(settings.get("app.settings.fontColor")).eq(undefined); + }); + + it("should not update values when unwatched setting changes", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + updateSetting("app.settings.fontSize", "50"); // unwatched setting + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(settings.get("app.settings.fontSize")).eq("40"); + }); + + it("should watch multiple settings if specified", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" }, + { key: "app.settings.fontSize" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + // change setting + addSetting("app.settings.bgColor", "white"); + updateSetting("app.settings.fontSize", "50"); + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(settings.get("app.settings.fontSize")).eq("50"); + expect(settings.get("app.settings.bgColor")).eq("white"); + }); + + it("should execute callbacks on successful refresh", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" } + ] + } + }); + let count = 0; + const callback = settings.onRefresh(() => count++); + + updateSetting("app.settings.fontColor", "blue"); + await settings.refresh(); + expect(count).eq(0); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(count).eq(1); + + // can dispose callbacks + callback.dispose(); + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(count).eq(1); + }); + + it("should not include watched settings into configuration if not specified in selectors", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [ + { keyFilter: "app.settings.fontColor" } + ], + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontSize" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).undefined; + }); + + it("should refresh when watched setting is added", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.bgColor" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + // add setting 'app.settings.bgColor' + addSetting("app.settings.bgColor", "white"); + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(settings.get("app.settings.bgColor")).eq("white"); + }); + + it("should not refresh when watched setting keeps not existing", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.bgColor" } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + // update an unwatched setting + updateSetting("app.settings.fontColor", "blue"); + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + // should not refresh + expect(settings.get("app.settings.fontColor")).eq("red"); + }); +}); \ No newline at end of file diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 616c758..5a01fb0 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -5,7 +5,7 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -import { createMockedConnectionString, createMockedTokenCredential } from "./utils/testHelper"; +import { createMockedConnectionString, createMockedKeyValue, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sleepInMs } from "./utils/testHelper"; import { load } from "./exportedApi"; class HttpRequestHeadersPolicy { headers: any; @@ -120,4 +120,32 @@ describe("request tracing", function () { // clean up delete process.env.AZURE_APP_CONFIGURATION_TRACING_DISABLED; }); + + it("should have request type in correlation-context header when refresh is enabled", async () => { + mockAppConfigurationClientListConfigurationSettings([{ + key: "app.settings.fontColor", + value: "red" + }].map(createMockedKeyValue)); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + refreshOptions: { + enabled: true, + refreshIntervalInMs: 1000, + watchedSettings: [{ + key: "app.settings.fontColor" + }] + } + }); + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("RequestType=Watch")).eq(true); + + restoreMocks(); + }); }); \ No newline at end of file diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 679069c..82f9c88 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -6,6 +6,9 @@ import { AppConfigurationClient, ConfigurationSetting } from "@azure/app-configu import { ClientSecretCredential } from "@azure/identity"; import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; import * as uuid from "uuid"; +import { RestError } from "@azure/core-rest-pipeline"; +import { promisify } from "util"; +const sleepInMs = promisify(setTimeout); const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000"; @@ -37,6 +40,21 @@ function mockAppConfigurationClientListConfigurationSettings(kvList: Configurati }); } +function mockAppConfigurationClientGetConfigurationSetting(kvList) { + sinon.stub(AppConfigurationClient.prototype, "getConfigurationSetting").callsFake((settingId, options) => { + const found = kvList.find(elem => elem.key === settingId.key && elem.label === settingId.label); + if (found) { + if (options?.onlyIfChanged && settingId.etag === found.etag) { + return { statusCode: 304 }; + } else { + return { statusCode: 200, ...found }; + } + } else { + throw new RestError("", { statusCode: 404 }); + } + }); +} + // uriValueList: [["", "value"], ...] function mockSecretClientGetSecret(uriValueList: [string, string][]) { const dict = new Map(); @@ -108,6 +126,7 @@ const createMockedKeyValue = (props: {[key: string]: any}): ConfigurationSetting export { sinon, mockAppConfigurationClientListConfigurationSettings, + mockAppConfigurationClientGetConfigurationSetting, mockSecretClientGetSecret, restoreMocks, @@ -116,5 +135,7 @@ export { createMockedTokenCredential, createMockedKeyVaultReference, createMockedJsonKeyValue, - createMockedKeyValue + createMockedKeyValue, + + sleepInMs } \ No newline at end of file