diff --git a/examples/README.md b/examples/README.md index 74b5ceed..9405d884 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,19 +4,26 @@ These examples show how to use the JavaScript Provider for Azure App Configurati ## Prerequisites -The sample programs are compatible with [LTS versions of Node.js](https://github.com/nodejs/release#release-schedule). +The examples are compatible with [LTS versions of Node.js](https://github.com/nodejs/release#release-schedule). -You need [an Azure subscription](https://azure.microsoft.com/free/) and the following Azure resources to run these sample programs: +You need [an Azure subscription](https://azure.microsoft.com/free/) and the following Azure resources to run the examples: - [Azure App Configuration store](https://learn.microsoft.com/en-us/azure/azure-app-configuration/quickstart-azure-app-configuration-create?tabs=azure-portal) -Samples retrieve credentials to access your App Configuration store from environment variables. +The examples retrieve credentials to access your App Configuration store from environment variables. Alternatively, edit the source code to include the appropriate credentials. -See each individual sample for details on which environment variables/credentials it requires to function. +See each individual example for details on which environment variables/credentials it requires to function. -## Setup +## Add a key-value +Add the following key-value to the App Configuration store and leave **Label** and **Content Type** with their default values. For more information about how to add key-values to a store using the Azure portal or the CLI, go to [Create a key-value](./quickstart-azure-app-configuration-create.md#create-a-key-value). -To run the samples using the published version of the package: +| Key | Value | +|------------------------|----------------| +| *app.settings.message* | *Hello World!* | + +## Setup & Run + +To run the examples using the published version of the package: 1. Install the dependencies using `npm`: @@ -24,15 +31,20 @@ To run the samples using the published version of the package: npm install ``` -2. There are two ways to run the samples using correct credentials: +2. There are two ways to run the examples using correct credentials: + + - Edit the file `.env.template`, adding the access keys to your App Configuration store. and rename the file from `.env.template` to just `.env`. The examples will read this file automatically. -- Edit the file `.env.template`, adding the correct credentials to access your Azure App Configuration store and rename the file from `.env.template` to just `.env`. -Then run the samples, it will read this file automatically. + - Alternatively, you can set the environment variables to the access keys to your App Configuration store. In this case, setting up the `.env` file is not required. + ```bash + npx cross-env APPCONFIG_CONNECTION_STRING="" + ``` + +3. Run the examples: ```bash node helloworld.mjs ``` - -- Alternatively, run a single sample with the correct environment variables set (setting up the `.env` file is not required if you do this), for example (cross-platform): - ```bash - npx cross-env APPCONFIG_CONNECTION_STRING="" node helloworld.mjs + You should see the following output: + ```Output + Message from Azure App Configuration: Hello World! ``` diff --git a/examples/configObject.mjs b/examples/configObject.mjs new file mode 100644 index 00000000..93de722a --- /dev/null +++ b/examples/configObject.mjs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as dotenv from "dotenv"; +dotenv.config() + +/** + * This example demonstrates how to construct a configuration object from settings loaded from Azure App Configuration. + * If you are using configuration object instead of Map-styled settings, it would minimize the code changes required to use Azure App Configuration in your application. + * + * When you import configuration into Azure App Configuration from a local .json file, the keys are automatically flattened with a separator if specified. + * E.g. if you import the following .json file, specifying the separator as ".": + * { + * "app": { + * "settings": { + * "message": "Hello, Azure!" + * } + * } + * } + * + * In the configuration explorer, the key-values will be: + * - Key: "app.settings.message", Value: "Hello, Azure!" + * + * With the API `constructConfigurationObject`, you can construct a configuration object with the same shape as the original .json file. + * The separator is used to split the keys and construct the object. + * The constructed object will be: { app: { settings: { message: "Hello, Azure!" } } } + * + * 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.*" + }] +}); + +const config = settings.constructConfigurationObject({ + separator: "." +}); + +console.log("Constructed object 'config': ", config); +console.log(`Message from Azure App Configuration: ${config.app.settings.message}`); diff --git a/examples/package.json b/examples/package.json index cb0fdf19..aa1a7d06 100644 --- a/examples/package.json +++ b/examples/package.json @@ -1,7 +1,7 @@ { "dependencies": { "@azure/app-configuration-provider": "latest", - "@azure/identity": "^3.3.0", + "@azure/identity": "^4.0.0", "dotenv": "^16.3.1" } } diff --git a/examples/refresh.mjs b/examples/refresh.mjs new file mode 100644 index 00000000..12231fcb --- /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/package-lock.json b/package-lock.json index 398cff78..d84f1ad5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.5.0", - "@azure/identity": "^3.3.2", + "@azure/identity": "^4.0.0", "@azure/keyvault-secrets": "^4.7.0" }, "devDependencies": { @@ -201,9 +201,9 @@ } }, "node_modules/@azure/identity": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-3.3.2.tgz", - "integrity": "sha512-aDLwgMXpNBEXOlfCP9r5Rn+inmbnTbadlOnrKI2dPS9Lpf4gHvpYBV+DEZKttakfJ+qn4iWWb7zONQSO3A4XSA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.0.0.tgz", + "integrity": "sha512-gtPYxIL0kI39Dw4t3HvlbfhOdXqKD2MqDgynlklF0j728j51dcKgRo6FLX0QzpBw/1gGfLxjMXqq3nKOSQ2lmA==", "dependencies": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.5.0", @@ -212,26 +212,16 @@ "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.0.0", "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^2.37.1", - "@azure/msal-common": "^13.1.0", - "@azure/msal-node": "^1.17.3", + "@azure/msal-browser": "^3.5.0", + "@azure/msal-node": "^2.5.1", "events": "^3.0.0", "jws": "^4.0.0", "open": "^8.0.0", "stoppable": "^1.1.0", - "tslib": "^2.2.0", - "uuid": "^8.3.0" + "tslib": "^2.2.0" }, "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@azure/identity/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" + "node": ">=18.0.0" } }, "node_modules/@azure/keyvault-secrets": { @@ -267,35 +257,35 @@ } }, "node_modules/@azure/msal-browser": { - "version": "2.38.2", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-2.38.2.tgz", - "integrity": "sha512-71BeIn2we6LIgMplwCSaMq5zAwmalyJR3jFcVOZxNVfQ1saBRwOD+P77nLs5vrRCedVKTq8RMFhIOdpMLNno0A==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.6.0.tgz", + "integrity": "sha512-FrFBJXRJMyWXjAjg4cUNZwEKktzfzD/YD9+S1kj2ors67hKoveam4aL0bZuCZU/jTiHTn0xDQGQh2ksCMXTXtA==", "dependencies": { - "@azure/msal-common": "13.3.0" + "@azure/msal-common": "14.5.0" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-13.3.0.tgz", - "integrity": "sha512-/VFWTicjcJbrGp3yQP7A24xU95NiDMe23vxIU1U6qdRPFsprMDNUohMudclnd+WSHE4/McqkZs/nUU3sAKkVjg==", + "version": "14.5.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.5.0.tgz", + "integrity": "sha512-Gx5rZbiZV/HiZ2nEKfjfAF/qDdZ4/QWxMvMo2jhIFVz528dVKtaZyFAOtsX2Ak8+TQvRsGCaEfuwJFuXB6tu1A==", "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-node": { - "version": "1.18.3", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.18.3.tgz", - "integrity": "sha512-lI1OsxNbS/gxRD4548Wyj22Dk8kS7eGMwD9GlBZvQmFV8FJUXoXySL1BiNzDsHUE96/DS/DHmA+F73p1Dkcktg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.6.0.tgz", + "integrity": "sha512-RWAWCYYrSldIYC47oWtofIun41e6SB9TBYgGYsezq6ednagwo9ZRFyRsvl1NabmdTkdDDXRAABIdveeN2Gtd8w==", "dependencies": { - "@azure/msal-common": "13.3.0", + "@azure/msal-common": "14.5.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, "engines": { - "node": "10 || 12 || 14 || 16 || 18" + "node": "16|| 18 || 20" } }, "node_modules/@azure/msal-node/node_modules/uuid": { @@ -3715,9 +3705,9 @@ } }, "@azure/identity": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-3.3.2.tgz", - "integrity": "sha512-aDLwgMXpNBEXOlfCP9r5Rn+inmbnTbadlOnrKI2dPS9Lpf4gHvpYBV+DEZKttakfJ+qn4iWWb7zONQSO3A4XSA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.0.0.tgz", + "integrity": "sha512-gtPYxIL0kI39Dw4t3HvlbfhOdXqKD2MqDgynlklF0j728j51dcKgRo6FLX0QzpBw/1gGfLxjMXqq3nKOSQ2lmA==", "requires": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.5.0", @@ -3726,22 +3716,13 @@ "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.0.0", "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^2.37.1", - "@azure/msal-common": "^13.1.0", - "@azure/msal-node": "^1.17.3", + "@azure/msal-browser": "^3.5.0", + "@azure/msal-node": "^2.5.1", "events": "^3.0.0", "jws": "^4.0.0", "open": "^8.0.0", "stoppable": "^1.1.0", - "tslib": "^2.2.0", - "uuid": "^8.3.0" - }, - "dependencies": { - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" - } + "tslib": "^2.2.0" } }, "@azure/keyvault-secrets": { @@ -3771,24 +3752,24 @@ } }, "@azure/msal-browser": { - "version": "2.38.2", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-2.38.2.tgz", - "integrity": "sha512-71BeIn2we6LIgMplwCSaMq5zAwmalyJR3jFcVOZxNVfQ1saBRwOD+P77nLs5vrRCedVKTq8RMFhIOdpMLNno0A==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.6.0.tgz", + "integrity": "sha512-FrFBJXRJMyWXjAjg4cUNZwEKktzfzD/YD9+S1kj2ors67hKoveam4aL0bZuCZU/jTiHTn0xDQGQh2ksCMXTXtA==", "requires": { - "@azure/msal-common": "13.3.0" + "@azure/msal-common": "14.5.0" } }, "@azure/msal-common": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-13.3.0.tgz", - "integrity": "sha512-/VFWTicjcJbrGp3yQP7A24xU95NiDMe23vxIU1U6qdRPFsprMDNUohMudclnd+WSHE4/McqkZs/nUU3sAKkVjg==" + "version": "14.5.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.5.0.tgz", + "integrity": "sha512-Gx5rZbiZV/HiZ2nEKfjfAF/qDdZ4/QWxMvMo2jhIFVz528dVKtaZyFAOtsX2Ak8+TQvRsGCaEfuwJFuXB6tu1A==" }, "@azure/msal-node": { - "version": "1.18.3", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.18.3.tgz", - "integrity": "sha512-lI1OsxNbS/gxRD4548Wyj22Dk8kS7eGMwD9GlBZvQmFV8FJUXoXySL1BiNzDsHUE96/DS/DHmA+F73p1Dkcktg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.6.0.tgz", + "integrity": "sha512-RWAWCYYrSldIYC47oWtofIun41e6SB9TBYgGYsezq6ednagwo9ZRFyRsvl1NabmdTkdDDXRAABIdveeN2Gtd8w==", "requires": { - "@azure/msal-common": "13.3.0", + "@azure/msal-common": "14.5.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, diff --git a/package.json b/package.json index 74eca211..c3ea60ff 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "dev": "rollup --config --watch", "lint": "eslint src/ test/", "fix-lint": "eslint src/ test/ --fix", - "test": "mocha out/test/*.test.{js,cjs,mjs} --timeout 10000" + "test": "mocha out/test/*.test.{js,cjs,mjs} --parallel" }, "repository": { "type": "git", @@ -56,7 +56,7 @@ }, "dependencies": { "@azure/app-configuration": "^1.5.0", - "@azure/identity": "^3.3.2", + "@azure/identity": "^4.0.0", "@azure/keyvault-secrets": "^4.7.0" } } diff --git a/scripts/build-and-pack.sh b/scripts/build-and-pack.sh new file mode 100755 index 00000000..dba0204c --- /dev/null +++ b/scripts/build-and-pack.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Stop on error. +set -e + +# Get the directory of the script. +SCRIPT_DIR=$(dirname $(readlink -f $0)) + +# Get the directory of the project. +PROJECT_BASE_DIR=$(dirname $SCRIPT_DIR) + +# Change to the project directory. +cd $PROJECT_BASE_DIR + +# Install dependencies, build, and test. +echo "npm clean install" +npm ci + +echo "npm run build" +npm run build + +echo "npm run test" +npm run test + +# Create a tarball. +echo "npm pack" +npm pack diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index a4af8746..716a60f7 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -1,6 +1,44 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { Disposable } from "./common/disposable"; + export type AzureAppConfiguration = { - // methods for advanced features, e.g. refresh() -} & ReadonlyMap; + /** + * 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; +} & IGettable & ReadonlyMap & IConfigurationObject; + +interface IConfigurationObject { + /** + * Construct configuration object based on Map-styled data structure and hierarchical keys. + * @param options - The options to control the conversion behavior. + */ + constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record; +} + +export interface ConfigurationObjectConstructionOptions { + /** + * The separator to use when converting hierarchical keys to object properties. + * Supported values: '.', ',', ';', '-', '_', '__', '/', ':'. + * If separator is undefined, '.' will be used by default. + */ + separator?: "." | "," | ";" | "-" | "_" | "__" | "/" | ":"; +} + +interface IGettable { + /** + * Get the value of a key-value from the Map-styled data structure. + * @param key - The key of the key-value to be retrieved. + */ + get(key: string): T | undefined; +} diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 3c682502..41e66a62 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,81 +1,326 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, ConfigurationSetting, ListConfigurationSettingsOptions } from "@azure/app-configuration"; -import { AzureAppConfiguration } from "./AzureAppConfiguration"; +import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions } from "@azure/app-configuration"; +import { RestError } from "@azure/core-rest-pipeline"; +import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } 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 { - private adapters: IKeyValueAdapter[] = []; +export class AzureAppConfigurationImpl implements AzureAppConfiguration { + /** + * Hosting key-value pairs in the configuration store. + */ + #configMap: Map = new Map(); + + #adapters: IKeyValueAdapter[] = []; /** * Trim key prefixes sorted in descending order. * Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. */ - private sortedTrimKeyPrefixes: string[] | undefined; - private readonly requestTracingEnabled: boolean; - private correlationContextHeader: string | undefined; + #sortedTrimKeyPrefixes: string[] | undefined; + readonly #requestTracingEnabled: boolean; + #client: AppConfigurationClient; + #options: AzureAppConfigurationOptions | undefined; + #isInitialLoadCompleted: boolean = false; + + // Refresh + #refreshInterval: number = DefaultRefreshIntervalInMs; + #onRefreshListeners: Array<() => any> = []; + /** + * Aka watched settings. + */ + #sentinels: ConfigurationSettingId[] = []; + #refreshTimer: RefreshTimer; constructor( - private client: AppConfigurationClient, - private options: AzureAppConfigurationOptions | undefined + client: AppConfigurationClient, + options: AzureAppConfigurationOptions | undefined ) { - super(); + this.#client = client; + this.#options = options; + // Enable request tracing if not opt-out - this.requestTracingEnabled = requestTracingEnabled(); - if (this.requestTracingEnabled) { - this.enableRequestTracing(); - } + this.#requestTracingEnabled = requestTracingEnabled(); if (options?.trimKeyPrefixes) { - this.sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); + 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()); + this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); + this.#adapters.push(new JsonKeyValueAdapter()); } - public async load() { - const keyValues: [key: string, value: unknown][] = []; + // ReadonlyMap APIs + get(key: string): T | undefined { + return this.#configMap.get(key); + } + + forEach(callbackfn: (value: any, key: string, map: ReadonlyMap) => void, thisArg?: any): void { + this.#configMap.forEach(callbackfn, thisArg); + } + + has(key: string): boolean { + return this.#configMap.has(key); + } + + get size(): number { + return this.#configMap.size; + } + + entries(): IterableIterator<[string, any]> { + return this.#configMap.entries(); + } + + keys(): IterableIterator { + return this.#configMap.keys(); + } + + values(): IterableIterator { + return this.#configMap.values(); + } + + [Symbol.iterator](): IterableIterator<[string, any]> { + return this.#configMap[Symbol.iterator](); + } + + get #refreshEnabled(): boolean { + return !!this.#options?.refreshOptions?.enabled; + } + + async #loadSelectedKeyValues(): Promise { + const loadedSettings: ConfigurationSetting[] = []; // validate selectors - const selectors = getValidSelectors(this.options?.selectors); + const selectors = getValidSelectors(this.#options?.selectors); for (const selector of selectors) { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter }; - if (this.requestTracingEnabled) { + if (this.#requestTracingEnabled) { listOptions.requestOptions = { - customHeaders: this.customHeaders() + customHeaders: { + [CorrelationContextHeaderName]: createCorrelationContextHeader(this.#options, this.#isInitialLoadCompleted) + } } } - const settings = this.client.listConfigurationSettings(listOptions); + 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.#configMap.clear(); // clear existing key-values in case of configuration setting deletion for (const [k, v] of keyValues) { - this.set(k, v); + this.#configMap.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; + } + + /** + * Construct hierarchical data object from map. + */ + constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record { + const separator = options?.separator ?? "."; + const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"]; + if (!validSeparators.includes(separator)) { + throw new Error(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`); + } + + // construct hierarchical data object from map + const data: Record = {}; + for (const [key, value] of this.#configMap) { + const segments = key.split(separator); + let current = data; + // construct hierarchical data object along the path + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + // undefined or empty string + if (!segment) { + throw new Error(`invalid key: ${key}`); + } + // create path if not exist + if (current[segment] === undefined) { + current[segment] = {}; + } + // The path has been occupied by a non-object value, causing ambiguity. + if (typeof current[segment] !== "object") { + throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The path '${segments.slice(0, i + 1).join(separator)}' has been occupied.`); + } + current = current[segment]; + } + + const lastSegment = segments[segments.length - 1]; + if (current[lastSegment] !== undefined) { + throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`); + } + // set value to the last segment + current[lastSegment] = value; } + return data; } - private async processAdapters(setting: ConfigurationSetting): Promise<[string, unknown]> { - for (const adapter of this.adapters) { + /** + * Refresh the configuration store. + */ + 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)) { return adapter.processKeyValue(setting); } @@ -83,9 +328,9 @@ export class AzureAppConfigurationImpl extends Map implements A return [setting.key, setting.value]; } - private keyWithPrefixesTrimmed(key: string): string { - if (this.sortedTrimKeyPrefixes) { - for (const prefix of this.sortedTrimKeyPrefixes) { + #keyWithPrefixesTrimmed(key: string): string { + if (this.#sortedTrimKeyPrefixes) { + for (const prefix of this.#sortedTrimKeyPrefixes) { if (key.startsWith(prefix)) { return key.slice(prefix.length); } @@ -94,18 +339,26 @@ export class AzureAppConfigurationImpl extends Map implements A return key; } - private enableRequestTracing() { - this.correlationContextHeader = createCorrelationContextHeader(this.options); - } - - private 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; } } @@ -138,4 +391,4 @@ function getValidSelectors(selectors?: SettingSelector[]) { } return selector; }); -} \ No newline at end of file +} diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 5da5fd18..b4532804 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -2,7 +2,8 @@ // Licensed under the MIT license. import { AppConfigurationClientOptions } from "@azure/app-configuration"; -import { AzureAppConfigurationKeyVaultOptions } from "./keyvault/AzureAppConfigurationKeyVaultOptions"; +import { KeyVaultOptions } from "./keyvault/KeyVaultOptions"; +import { RefreshOptions } from "./RefreshOptions"; import { SettingSelector } from "./types"; export const MaxRetries = 2; @@ -11,12 +12,16 @@ export const MaxRetryDelayInMs = 60000; export interface AzureAppConfigurationOptions { /** * Specify what key-values to include in the configuration provider. + * + * @remarks * If no selectors are specified then all key-values with no label will be included. */ selectors?: SettingSelector[]; /** * Specifies prefixes to be trimmed from the keys of all key-values retrieved from Azure App Configuration. + * + * @remarks * This is useful when you want to remove a common prefix from all keys to avoid repetition. * The provided prefixes will be sorted in descending order and the longest matching prefix will be trimmed first. */ @@ -30,5 +35,9 @@ export interface AzureAppConfigurationOptions { /** * Specifies options used to resolve Vey Vault references. */ - keyVaultOptions?: AzureAppConfigurationKeyVaultOptions; + keyVaultOptions?: KeyVaultOptions; + /** + * Specifies options for dynamic refresh key-values. + */ + refreshOptions?: RefreshOptions; } \ No newline at end of file diff --git a/src/JsonKeyValueAdapter.ts b/src/JsonKeyValueAdapter.ts index 44daaee1..4be758a1 100644 --- a/src/JsonKeyValueAdapter.ts +++ b/src/JsonKeyValueAdapter.ts @@ -4,24 +4,23 @@ import { ConfigurationSetting, secretReferenceContentType } from "@azure/app-configuration"; import { IKeyValueAdapter } from "./IKeyValueAdapter"; - export class JsonKeyValueAdapter implements IKeyValueAdapter { - private static readonly ExcludedJsonContentTypes: string[] = [ + static readonly #ExcludedJsonContentTypes: string[] = [ secretReferenceContentType // TODO: exclude application/vnd.microsoft.appconfig.ff+json after feature management is supported ]; - public canProcess(setting: ConfigurationSetting): boolean { + canProcess(setting: ConfigurationSetting): boolean { if (!setting.contentType) { return false; } - if (JsonKeyValueAdapter.ExcludedJsonContentTypes.includes(setting.contentType)) { + if (JsonKeyValueAdapter.#ExcludedJsonContentTypes.includes(setting.contentType)) { return false; } return isJsonContentType(setting.contentType); } - public async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { + async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { let parsedValue: unknown; if (setting.value !== undefined) { try { diff --git a/src/RefreshOptions.ts b/src/RefreshOptions.ts new file mode 100644 index 00000000..28a0d03f --- /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 00000000..b714f2ef --- /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 00000000..4843e121 --- /dev/null +++ b/src/common/disposable.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export class Disposable { + #disposed = false; + #callOnDispose: () => any; + + constructor(callOnDispose: () => any) { + this.#callOnDispose = callOnDispose; + } + + 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 524dbec7..dd246046 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/keyvault/AzureKeyVaultKeyValueAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts index 3f5326fc..b65a0314 100644 --- a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -3,26 +3,27 @@ import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration"; import { IKeyValueAdapter } from "../IKeyValueAdapter"; -import { AzureAppConfigurationKeyVaultOptions } from "./AzureAppConfigurationKeyVaultOptions"; +import { KeyVaultOptions } from "./KeyVaultOptions"; import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { /** * Map vault hostname to corresponding secret client. */ - private secretClients: Map; + #secretClients: Map; + #keyVaultOptions: KeyVaultOptions | undefined; - constructor( - private keyVaultOptions: AzureAppConfigurationKeyVaultOptions | undefined - ) { } + constructor(keyVaultOptions: KeyVaultOptions | undefined) { + this.#keyVaultOptions = keyVaultOptions; + } - public canProcess(setting: ConfigurationSetting): boolean { + canProcess(setting: ConfigurationSetting): boolean { return isSecretReference(setting); } - public async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { + async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { // TODO: cache results to save requests. - if (!this.keyVaultOptions) { + if (!this.#keyVaultOptions) { throw new Error("Configure keyVaultOptions to resolve Key Vault Reference(s)."); } @@ -31,37 +32,37 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { parseSecretReference(setting).value.secretId ); - const client = this.getSecretClient(new URL(vaultUrl)); + const client = this.#getSecretClient(new URL(vaultUrl)); if (client) { // TODO: what if error occurs when reading a key vault value? Now it breaks the whole load. const secret = await client.getSecret(secretName, { version }); return [setting.key, secret.value]; } - if (this.keyVaultOptions.secretResolver) { - return [setting.key, await this.keyVaultOptions.secretResolver(new URL(sourceId))]; + if (this.#keyVaultOptions.secretResolver) { + return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(sourceId))]; } throw new Error("No key vault credential or secret resolver callback configured, and no matching secret client could be found."); } - private getSecretClient(vaultUrl: URL): SecretClient | undefined { - if (this.secretClients === undefined) { - this.secretClients = new Map(); - for (const c of this.keyVaultOptions?.secretClients ?? []) { - this.secretClients.set(getHost(c.vaultUrl), c); + #getSecretClient(vaultUrl: URL): SecretClient | undefined { + if (this.#secretClients === undefined) { + this.#secretClients = new Map(); + for (const c of this.#keyVaultOptions?.secretClients ?? []) { + this.#secretClients.set(getHost(c.vaultUrl), c); } } let client: SecretClient | undefined; - client = this.secretClients.get(vaultUrl.host); + client = this.#secretClients.get(vaultUrl.host); if (client !== undefined) { return client; } - if (this.keyVaultOptions?.credential) { - client = new SecretClient(vaultUrl.toString(), this.keyVaultOptions.credential); - this.secretClients.set(vaultUrl.host, client); + if (this.#keyVaultOptions?.credential) { + client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential); + this.#secretClients.set(vaultUrl.host, client); return client; } diff --git a/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts b/src/keyvault/KeyVaultOptions.ts similarity index 91% rename from src/keyvault/AzureAppConfigurationKeyVaultOptions.ts rename to src/keyvault/KeyVaultOptions.ts index 2b5c7d87..88e86a9c 100644 --- a/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts +++ b/src/keyvault/KeyVaultOptions.ts @@ -7,7 +7,7 @@ import { SecretClient } from "@azure/keyvault-secrets"; /** * Options used to resolve Key Vault references. */ -export interface AzureAppConfigurationKeyVaultOptions { +export interface KeyVaultOptions { /** * Specifies the Key Vault secret client used for resolving Key Vault references. */ diff --git a/src/load.ts b/src/load.ts index a44d4042..b98db7d4 100644 --- a/src/load.ts +++ b/src/load.ts @@ -8,12 +8,15 @@ import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl"; import { AzureAppConfigurationOptions, MaxRetries, MaxRetryDelayInMs } from "./AzureAppConfigurationOptions"; import * as RequestTracing from "./requestTracing/constants"; +const MinDelayForUnhandedError: number = 5000; // 5 seconds + /** * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. * @param connectionString The connection string for the App Configuration store. * @param options Optional parameters. */ export async function load(connectionString: string, options?: AzureAppConfigurationOptions): Promise; + /** * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. * @param endpoint The URL to the App Configuration store. @@ -21,13 +24,17 @@ export async function load(connectionString: string, options?: AzureAppConfigura * @param options Optional parameters. */ export async function load(endpoint: URL | string, credential: TokenCredential, options?: AzureAppConfigurationOptions): Promise; + export async function load( connectionStringOrEndpoint: string | URL, credentialOrOptions?: TokenCredential | AzureAppConfigurationOptions, appConfigOptions?: AzureAppConfigurationOptions ): Promise { + const startTimestamp = Date.now(); let client: AppConfigurationClient; let options: AzureAppConfigurationOptions | undefined; + + // input validation if (typeof connectionStringOrEndpoint === "string" && !instanceOfTokenCredential(credentialOrOptions)) { const connectionString = connectionStringOrEndpoint; options = credentialOrOptions as AzureAppConfigurationOptions; @@ -41,7 +48,7 @@ export async function load( endpoint = new URL(endpoint); } catch (error) { if (error.code === "ERR_INVALID_URL") { - throw new Error("Invalid Endpoint URL.", { cause: error }); + throw new Error("Invalid endpoint URL.", { cause: error }); } else { throw error; } @@ -55,9 +62,20 @@ export async function load( throw new Error("A connection string or an endpoint with credential must be specified to create a client."); } - const appConfiguration = new AzureAppConfigurationImpl(client, options); - await appConfiguration.load(); - return appConfiguration; + try { + const appConfiguration = new AzureAppConfigurationImpl(client, options); + await appConfiguration.load(); + return appConfiguration; + } catch (error) { + // load() method is called in the application's startup code path. + // Unhandled exceptions cause application crash which can result in crash loops as orchestrators attempt to restart the application. + // Knowing the intended usage of the provider in startup code path, we mitigate back-to-back crash loops from overloading the server with requests by waiting a minimum time to propagate fatal errors. + const delay = MinDelayForUnhandedError - (Date.now() - startTimestamp); + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + throw error; + } } function instanceOfTokenCredential(obj: unknown) { diff --git a/src/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts new file mode 100644 index 00000000..3ae824b1 --- /dev/null +++ b/src/refresh/RefreshTimer.ts @@ -0,0 +1,85 @@ +// 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 { + #minBackoff: number = MinimumBackoffInMs; + #maxBackoff: number = MaximumBackoffInMs; + #failedAttempts: number = 0; + #backoffEnd: number; // Timestamp + #interval: number; + + constructor( + interval: number + ) { + if (interval <= 0) { + throw new Error(`Refresh interval must be greater than 0. Given: ${this.#interval}`); + } + + this.#interval = interval; + this.#backoffEnd = Date.now() + this.#interval; + } + + canRefresh(): boolean { + return Date.now() >= this.#backoffEnd; + } + + backoff(): void { + this.#failedAttempts += 1; + this.#backoffEnd = Date.now() + this.#calculateBackoffTime(); + } + + reset(): void { + this.#failedAttempts = 0; + this.#backoffEnd = Date.now() + this.#interval; + } + + #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 95483bfa..8966a308 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/src/types.ts b/src/types.ts index 0b51f905..a8181378 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,43 +2,51 @@ // Licensed under the MIT license. /** - * SettingSelector is used to select key-values from Azure App Configuration. - * It is used to filter key-values based on keys and labels. - * - * @property keyFilter: - * The key filter to apply when querying Azure App Configuration for key-values. - * An asterisk `*` can be added to the end to return all key-values whose key begins with the key filter. - * e.g. key filter `abc*` returns all key-values whose key starts with `abc`. - * A comma `,` can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. - * Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. - * E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. - * For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\). - * e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. - * - * @property labelFilter: - * The label filter to apply when querying Azure App Configuration for key-values. - * By default, the "null label" will be used, matching key-values without a label. - * The characters asterisk `*` and comma `,` are not supported. - * Backslash `\` character is reserved and must be escaped using another backslash `\`. + * SettingSelector is used to select key-values from Azure App Configuration based on keys and labels. */ -export type SettingSelector = { keyFilter: string, labelFilter?: string }; +export type SettingSelector = { + /** + * The key filter to apply when querying Azure App Configuration for key-values. + * + * @remarks + * An asterisk `*` can be added to the end to return all key-values whose key begins with the key filter. + * e.g. key filter `abc*` returns all key-values whose key starts with `abc`. + * A comma `,` can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. + * Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. + * E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. + * For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\). + * e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. + */ + keyFilter: string, + + /** + * The label filter to apply when querying Azure App Configuration for key-values. + * + * @remarks + * The characters asterisk `*` and comma `,` are not supported. + * Backslash `\` character is reserved and must be escaped using another backslash `\`. + * + * @defaultValue `LabelFilter.Null`, matching key-values without a label. + */ + labelFilter?: string +}; /** * KeyFilter is used to filter key-values based on keys. - * - * @property Any: - * Matches all key-values. */ export enum KeyFilter { + /** + * Matches all key-values. + */ Any = "*" } /** * LabelFilter is used to filter key-values based on labels. - * - * @property Null: - * Matches key-values without a label. */ export enum LabelFilter { + /** + * Matches key-values without a label. + */ Null = "\0" } diff --git a/test/clientOptions.test.ts b/test/clientOptions.test.ts index 2bf146f1..0b970d93 100644 --- a/test/clientOptions.test.ts +++ b/test/clientOptions.test.ts @@ -27,6 +27,8 @@ class HttpRequestCountPolicy { } describe("custom client options", function () { + this.timeout(15000); + const fakeEndpoint = "https://azure.azconfig.io"; beforeEach(() => { // Thus here mock it to reply 500, in which case the retry mechanism works. diff --git a/test/json.test.ts b/test/json.test.ts index 4818e9b2..0d7e9484 100644 --- a/test/json.test.ts +++ b/test/json.test.ts @@ -25,7 +25,7 @@ describe("json", function () { const connectionString = createMockedConnectionString(); const settings = await load(connectionString); expect(settings).not.undefined; - const logging = settings.get("json.settings.logging"); + const logging = settings.get("json.settings.logging"); expect(logging).not.undefined; expect(logging.Test).not.undefined; expect(logging.Test.Level).eq("Debug"); @@ -43,7 +43,7 @@ describe("json", function () { } }); expect(settings).not.undefined; - const resolvedSecret = settings.get("TestKey"); + const resolvedSecret = settings.get("TestKey"); expect(resolvedSecret).not.undefined; expect(resolvedSecret.uri).undefined; expect(typeof resolvedSecret).eq("string"); @@ -74,7 +74,7 @@ describe("json", function () { const settings = await load(connectionString); expect(settings).not.undefined; expect(typeof settings.get("json.settings.object")).eq("object", "is object"); - expect(Object.keys(settings.get("json.settings.object")).length).eq(0, "is empty object"); + expect(Object.keys(settings.get("json.settings.object")).length).eq(0, "is empty object"); expect(Array.isArray(settings.get("json.settings.array"))).eq(true, "is array"); expect(settings.get("json.settings.number")).eq(8, "is number"); expect(settings.get("json.settings.string")).eq("string", "is string"); diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index c5ac332e..194517a0 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -27,6 +27,8 @@ function mockNewlyCreatedKeyVaultSecretClients() { mockSecretClientGetSecret(mockedData.map(([_key, secretUri, value]) => [secretUri, value])); } describe("key vault reference", function () { + this.timeout(10000); + beforeEach(() => { mockAppConfigurationClient(); mockNewlyCreatedKeyVaultSecretClients(); @@ -37,7 +39,7 @@ describe("key vault reference", function () { }); it("require key vault options to resolve reference", async () => { - expect(load(createMockedConnectionString())).eventually.rejected; + return expect(load(createMockedConnectionString())).eventually.rejectedWith("Configure keyVaultOptions to resolve Key Vault Reference(s)."); }); it("should resolve key vault reference with credential", async () => { @@ -93,7 +95,7 @@ describe("key vault reference", function () { ] } }); - expect(loadKeyVaultPromise).eventually.rejected; + return expect(loadKeyVaultPromise).eventually.rejectedWith("No key vault credential or secret resolver callback configured, and no matching secret client could be found."); }); it("should fallback to use default credential when corresponding secret client not provided", async () => { diff --git a/test/load.test.ts b/test/load.test.ts index a0b89691..63f1cfd1 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -14,6 +14,18 @@ const mockedKVs = [{ }, { key: "app.settings.fontSize", value: "40", +}, { + key: "app/settings/fontColor", + value: "red", +}, { + key: "app/settings/fontSize", + value: "40", +}, { + key: "app%settings%fontColor", + value: "red", +}, { + key: "app%settings%fontSize", + value: "40", }, { key: "TestKey", label: "Test", @@ -28,9 +40,34 @@ const mockedKVs = [{ }, { key: "KeyForEmptyValue", value: "", -}].map(createMockedKeyValue); +}, { + key: "app2.settings", + value: JSON.stringify({ fontColor: "blue", fontSize: 20 }), + contentType: "application/json" +}, { + key: "app3.settings", + value: "placeholder" +}, { + key: "app3.settings.fontColor", + value: "yellow" +}, { + key: "app4.excludedFolders.0", + value: "node_modules" +}, { + key: "app4.excludedFolders.1", + value: "dist" +}, { + key: "app5.settings.fontColor", + value: "yellow" +}, { + key: "app5.settings", + value: "placeholder" +} +].map(createMockedKeyValue); describe("load", function () { + this.timeout(10000); + before(() => { mockAppConfigurationClientListConfigurationSettings(mockedKVs); }); @@ -65,12 +102,80 @@ describe("load", function () { }); it("should throw error given invalid connection string", async () => { - expect(load("invalid-connection-string")).eventually.rejected; + return expect(load("invalid-connection-string")).eventually.rejectedWith("Invalid connection string."); }); it("should throw error given invalid endpoint URL", async () => { const credential = createMockedTokenCredential(); - expect(load("invalid-endpoint-url", credential)).eventually.rejected; + return expect(load("invalid-endpoint-url", credential)).eventually.rejectedWith("Invalid endpoint URL."); + }); + + it("should filter by key and label, has(key) and get(key) should work", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app.settings.*", + labelFilter: "\0" + }] + }); + expect(settings).not.undefined; + expect(settings.has("app.settings.fontColor")).true; + expect(settings.has("app.settings.fontSize")).true; + expect(settings.has("app.settings.fontFamily")).false; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + expect(settings.get("app.settings.fontFamily")).undefined; + }); + + it("should also work with other ReadonlyMap APIs", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app.settings.*", + labelFilter: "\0" + }] + }); + expect(settings).not.undefined; + // size + expect(settings.size).eq(2); + // keys() + expect(Array.from(settings.keys())).deep.eq(["app.settings.fontColor", "app.settings.fontSize"]); + // values() + expect(Array.from(settings.values())).deep.eq(["red", "40"]); + // entries() + expect(Array.from(settings.entries())).deep.eq([["app.settings.fontColor", "red"], ["app.settings.fontSize", "40"]]); + // forEach() + const keys: string[] = []; + const values: string[] = []; + settings.forEach((value, key) => { + keys.push(key); + values.push(value); + }); + expect(keys).deep.eq(["app.settings.fontColor", "app.settings.fontSize"]); + expect(values).deep.eq(["red", "40"]); + // [Symbol.iterator]() + const entries: [string, string][] = []; + for (const [key, value] of settings) { + entries.push([key, value]); + } + expect(entries).deep.eq([["app.settings.fontColor", "red"], ["app.settings.fontSize", "40"]]); + }); + + it("should be read-only, set(key, value) should not work", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app.settings.*", + labelFilter: "\0" + }] + }); + expect(settings).not.undefined; + expect(() => { + // Here force to turn if off for testing purpose, as JavaScript does not have type checking. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + settings.set("app.settings.fontColor", "blue"); + }).to.throw("settings.set is not a function"); }); it("should trim key prefix if applicable", async () => { @@ -83,11 +188,8 @@ describe("load", function () { trimKeyPrefixes: ["app.settings."] }); expect(settings).not.undefined; - expect(settings.has("fontColor")).eq(true); expect(settings.get("fontColor")).eq("red"); - expect(settings.has("fontSize")).eq(true); expect(settings.get("fontSize")).eq("40"); - expect(settings.has("TestKey")).eq(false); }); it("should trim longest key prefix first", async () => { @@ -100,24 +202,19 @@ describe("load", function () { trimKeyPrefixes: ["app.", "app.settings.", "Test"] }); expect(settings).not.undefined; - expect(settings.has("fontColor")).eq(true); expect(settings.get("fontColor")).eq("red"); - expect(settings.has("fontSize")).eq(true); expect(settings.get("fontSize")).eq("40"); - expect(settings.has("TestKey")).eq(false); }); it("should support null/empty value", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString); expect(settings).not.undefined; - expect(settings.has("KeyForNullValue")).eq(true); expect(settings.get("KeyForNullValue")).eq(null); - expect(settings.has("KeyForEmptyValue")).eq(true); expect(settings.get("KeyForEmptyValue")).eq(""); }); - it("should not support * or , in label filters", async () => { + it("should not support * in label filters", async () => { const connectionString = createMockedConnectionString(); const loadWithWildcardLabelFilter = load(connectionString, { selectors: [{ @@ -125,15 +222,18 @@ describe("load", function () { labelFilter: "*" }] }); - expect(loadWithWildcardLabelFilter).to.eventually.rejected; + return expect(loadWithWildcardLabelFilter).to.eventually.rejectedWith("The characters '*' and ',' are not supported in label filters."); + }); + it("should not support , in label filters", async () => { + const connectionString = createMockedConnectionString(); const loadWithMultipleLabelFilter = load(connectionString, { selectors: [{ keyFilter: "app.*", labelFilter: "labelA,labelB" }] }); - expect(loadWithMultipleLabelFilter).to.eventually.rejected; + return expect(loadWithMultipleLabelFilter).to.eventually.rejectedWith("The characters '*' and ',' are not supported in label filters."); }); it("should override config settings with same key but different label", async () => { @@ -148,7 +248,6 @@ describe("load", function () { }] }); expect(settings).not.undefined; - expect(settings.has("TestKey")).eq(true); expect(settings.get("TestKey")).eq("TestValueForProd"); }); @@ -167,8 +266,137 @@ describe("load", function () { }] }); expect(settings).not.undefined; - expect(settings.has("TestKey")).eq(true); expect(settings.get("TestKey")).eq("TestValueForProd"); }); + // access data property + it("should directly access data property", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app.settings.*" + }] + }); + expect(settings).not.undefined; + const data = settings.constructConfigurationObject(); + expect(data).not.undefined; + expect(data.app.settings.fontColor).eq("red"); + expect(data.app.settings.fontSize).eq("40"); + }); + + it("should access property of JSON object content-type with data accessor", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app2.*" + }] + }); + expect(settings).not.undefined; + const data = settings.constructConfigurationObject(); + expect(data).not.undefined; + expect(data.app2.settings.fontColor).eq("blue"); + expect(data.app2.settings.fontSize).eq(20); + }); + + it("should not access property of JSON content-type object with get()", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app2.*" + }] + }); + expect(settings).not.undefined; + expect(settings.get("app2.settings")).not.undefined; // JSON object accessed as a whole + expect(settings.get("app2.settings.fontColor")).undefined; + expect(settings.get("app2.settings.fontSize")).undefined; + }); + + /** + * Edge case: Hierarchical key-value pairs with overlapped key prefix. + * key: "app3.settings" => value: "placeholder" + * key: "app3.settings.fontColor" => value: "yellow" + * + * get() will return "placeholder" for "app3.settings" and "yellow" for "app3.settings.fontColor", as expected. + * data.app3.settings will return "placeholder" as a whole JSON object, which is not guarenteed to be correct. + */ + it("Edge case 1: Hierarchical key-value pairs with overlapped key prefix.", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app3.settings*" + }] + }); + expect(settings).not.undefined; + expect(() => { + settings.constructConfigurationObject(); + }).to.throw("Ambiguity occurs when constructing configuration object from key 'app3.settings.fontColor', value 'yellow'. The path 'app3.settings' has been occupied."); + }); + + /** + * Edge case: Hierarchical key-value pairs with overlapped key prefix. + * key: "app5.settings.fontColor" => value: "yellow" + * key: "app5.settings" => value: "placeholder" + * + * When ocnstructConfigurationObject() is called, it first constructs from key "app5.settings.fontColor" and then from key "app5.settings". + * An error will be thrown when constructing from key "app5.settings" because there is ambiguity between the two keys. + */ + it("Edge case 1: Hierarchical key-value pairs with overlapped key prefix.", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app5.settings*" + }] + }); + expect(settings).not.undefined; + expect(() => { + settings.constructConfigurationObject(); + }).to.throw("Ambiguity occurs when constructing configuration object from key 'app5.settings', value 'placeholder'. The key should not be part of another key."); + }); + + it("should construct configuration object with array", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app4.*" + }] + }); + expect(settings).not.undefined; + const data = settings.constructConfigurationObject(); + expect(data).not.undefined; + // Both { '0': 'node_modules', '1': 'dist' } and ['node_modules', 'dist'] are valid. + expect(data.app4.excludedFolders[0]).eq("node_modules"); + expect(data.app4.excludedFolders[1]).eq("dist"); + }); + + it("should construct configuration object with customized separator", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app/settings/*" + }] + }); + expect(settings).not.undefined; + const data = settings.constructConfigurationObject({ separator: "/" }); + expect(data).not.undefined; + expect(data.app.settings.fontColor).eq("red"); + expect(data.app.settings.fontSize).eq("40"); + }); + + it("should throw error when construct configuration object with invalid separator", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app%settings%*" + }] + }); + expect(settings).not.undefined; + + expect(() => { + // Below line will throw error because of type checking, i.e. Type '"%"' is not assignable to type '"/" | "." | "," | ";" | "-" | "_" | "__" | ":" | undefined'.ts(2322) + // Here force to turn if off for testing purpose, as JavaScript does not have type checking. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + settings.constructConfigurationObject({ separator: "%" }); + }).to.throw("Invalid separator '%'. Supported values: '.', ',', ';', '-', '_', '__', '/', ':'."); + }); }); diff --git a/test/refresh.test.ts b/test/refresh.test.ts new file mode 100644 index 00000000..2378856f --- /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 aaedae20..5a01fb04 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; @@ -22,6 +22,8 @@ class HttpRequestHeadersPolicy { } describe("request tracing", function () { + this.timeout(15000); + const fakeEndpoint = "https://127.0.0.1"; // sufficient to test the request it sends out const headerPolicy = new HttpRequestHeadersPolicy(); const position: "perCall" | "perRetry" = "perCall"; @@ -118,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 679069c4..82f9c888 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