From f54e65b82542029fc936d445add3154d4ce04fa4 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Mon, 18 Dec 2023 10:37:14 +0800 Subject: [PATCH 01/17] Add error handling delay in load function (#35) --- package.json | 2 +- src/load.ts | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 74eca211..0bf89104 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} --timeout 15000" }, "repository": { "type": "git", diff --git a/src/load.ts b/src/load.ts index a44d4042..a7007136 100644 --- a/src/load.ts +++ b/src/load.ts @@ -8,6 +8,8 @@ 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. @@ -26,8 +28,11 @@ export async function load( 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; @@ -55,9 +60,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) { From 0adf2ff9e55c7e63e72545725a80e32d144b0403 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Tue, 26 Dec 2023 07:42:39 +0800 Subject: [PATCH 02/17] update doc comments per TSDoc standard (#36) --- src/AzureAppConfigurationOptions.ts | 4 ++ src/load.ts | 2 + src/types.ts | 57 ++++++++++++++++------------- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 5da5fd18..104c8451 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -11,12 +11,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. */ diff --git a/src/load.ts b/src/load.ts index a7007136..6e52b528 100644 --- a/src/load.ts +++ b/src/load.ts @@ -16,6 +16,7 @@ const MinDelayForUnhandedError: number = 5000; // 5 seconds * @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. @@ -23,6 +24,7 @@ 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, diff --git a/src/types.ts b/src/types.ts index 0b51f905..8f39c493 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,43 +2,50 @@ // 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 + * 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 `\`. + */ + 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" } From 85c01752254696e0e736c49218006aa9411202c2 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 3 Jan 2024 15:05:05 +0800 Subject: [PATCH 03/17] Update README for examples (#41) * update readme * add colon --- examples/README.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/examples/README.md b/examples/README.md index 74b5ceed..5ba5127f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,9 +14,16 @@ Samples retrieve credentials to access your App Configuration store from environ 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. -## 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 example programs 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 example: ```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! ``` From f881f582502a6d692957b6dcc674f6f4691c61d8 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 3 Jan 2024 15:36:51 +0800 Subject: [PATCH 04/17] Unify the wording in README for examples (#42) * update readme * add colon * rename sample to example * update * update * update --- examples/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/README.md b/examples/README.md index 5ba5127f..9405d884 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,18 +4,18 @@ 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. ## 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). +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). | Key | Value | |------------------------|----------------| @@ -33,14 +33,14 @@ To run the examples using the published version of the package: 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 example programs will read this file automatically. + - 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. - 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 example: +3. Run the examples: ```bash node helloworld.mjs ``` From 5c66956da08fcd850b9073ce9160b355763b10d2 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Wed, 3 Jan 2024 15:44:56 +0800 Subject: [PATCH 05/17] Hide private properties (#37) --- src/AzureAppConfigurationImpl.ts | 63 +++++++++++++++++--------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 3c682502..9f6eb837 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -13,58 +13,63 @@ import { createCorrelationContextHeader, requestTracingEnabled } from "./request import { SettingSelector } from "./types"; export class AzureAppConfigurationImpl extends Map implements AzureAppConfiguration { - private adapters: IKeyValueAdapter[] = []; + #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; + #correlationContextHeader: string | undefined; + #client: AppConfigurationClient; + #options: AzureAppConfigurationOptions | undefined; 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 (this.#requestTracingEnabled) { + this.#enableRequestTracing(); } if (options?.trimKeyPrefixes) { - this.sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); + this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); } // 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() { + async load() { const keyValues: [key: string, value: unknown][] = []; // 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: this.#customHeaders() } } - 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); + const [key, value] = await this.#processAdapters(setting); + const trimmedKey = this.#keyWithPrefixesTrimmed(key); keyValues.push([trimmedKey, value]); } } @@ -74,8 +79,8 @@ export class AzureAppConfigurationImpl extends Map implements A } } - private async processAdapters(setting: ConfigurationSetting): Promise<[string, unknown]> { - for (const adapter of this.adapters) { + async #processAdapters(setting: ConfigurationSetting): Promise<[string, unknown]> { + for (const adapter of this.#adapters) { if (adapter.canProcess(setting)) { return adapter.processKeyValue(setting); } @@ -83,9 +88,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,17 +99,17 @@ export class AzureAppConfigurationImpl extends Map implements A return key; } - private enableRequestTracing() { - this.correlationContextHeader = createCorrelationContextHeader(this.options); + #enableRequestTracing() { + this.#correlationContextHeader = createCorrelationContextHeader(this.#options); } - private customHeaders() { - if (!this.requestTracingEnabled) { + #customHeaders() { + if (!this.#requestTracingEnabled) { return undefined; } const headers = {}; - headers[CorrelationContextHeaderName] = this.correlationContextHeader; + headers[CorrelationContextHeaderName] = this.#correlationContextHeader; return headers; } } From a07651180fca7a0e193e230340833dba232355b2 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:10:12 +0800 Subject: [PATCH 06/17] Update labelFilter default value in SettingSelector (#43) --- src/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 8f39c493..a8181378 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,9 +23,10 @@ export type SettingSelector = { * The label filter to apply when querying Azure App Configuration for key-values. * * @remarks - * 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 `\`. + * + * @defaultValue `LabelFilter.Null`, matching key-values without a label. */ labelFilter?: string }; From 534202144dc5029e1edd5ccf6e76f30a7b47e400 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Thu, 4 Jan 2024 09:41:18 +0800 Subject: [PATCH 07/17] Update @azure/identity version to 4.0.0 (#39) --- examples/package.json | 2 +- package-lock.json | 93 +++++++++++++++++-------------------------- package.json | 2 +- 3 files changed, 39 insertions(+), 58 deletions(-) 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/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 0bf89104..5d4aa135 100644 --- a/package.json +++ b/package.json @@ -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" } } From 68a8458230ce63cda95e1df51454a60ba575cf94 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Thu, 4 Jan 2024 09:41:40 +0800 Subject: [PATCH 08/17] run mocha tests in parallel (#40) --- package.json | 2 +- test/clientOptions.test.ts | 2 ++ test/requestTracing.test.ts | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d4aa135..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 15000" + "test": "mocha out/test/*.test.{js,cjs,mjs} --parallel" }, "repository": { "type": "git", 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/requestTracing.test.ts b/test/requestTracing.test.ts index aaedae20..616c7588 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -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"; From 794559dd2daaddc4bfe1bb821230e5981bac73f8 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Fri, 5 Jan 2024 08:52:46 +0800 Subject: [PATCH 09/17] Rename AzureAppConfigurationKeyVaultOptions to KeyVaultOptions (#44) --- src/AzureAppConfigurationOptions.ts | 4 ++-- src/keyvault/AzureKeyVaultKeyValueAdapter.ts | 4 ++-- ...eAppConfigurationKeyVaultOptions.ts => KeyVaultOptions.ts} | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename src/keyvault/{AzureAppConfigurationKeyVaultOptions.ts => KeyVaultOptions.ts} (91%) diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 104c8451..978338d4 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { AppConfigurationClientOptions } from "@azure/app-configuration"; -import { AzureAppConfigurationKeyVaultOptions } from "./keyvault/AzureAppConfigurationKeyVaultOptions"; +import { KeyVaultOptions } from "./keyvault/KeyVaultOptions"; import { SettingSelector } from "./types"; export const MaxRetries = 2; @@ -34,5 +34,5 @@ export interface AzureAppConfigurationOptions { /** * Specifies options used to resolve Vey Vault references. */ - keyVaultOptions?: AzureAppConfigurationKeyVaultOptions; + keyVaultOptions?: KeyVaultOptions; } \ No newline at end of file diff --git a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts index 3f5326fc..b7025d58 100644 --- a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -3,7 +3,7 @@ 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 { @@ -13,7 +13,7 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { private secretClients: Map; constructor( - private keyVaultOptions: AzureAppConfigurationKeyVaultOptions | undefined + private keyVaultOptions: KeyVaultOptions | undefined ) { } public canProcess(setting: ConfigurationSetting): boolean { 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. */ From 277953ddf7fcd3e9e7b8c67074946d14462b32ac Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Thu, 18 Jan 2024 17:00:12 +0800 Subject: [PATCH 10/17] Support dynamic refresh (#21) --- examples/refresh.mjs | 44 ++++ src/AzureAppConfiguration.ts | 15 +- src/AzureAppConfigurationImpl.ts | 224 ++++++++++++++++--- src/AzureAppConfigurationOptions.ts | 5 + src/RefreshOptions.ts | 27 +++ src/WatchedSetting.ts | 18 ++ src/common/disposable.ts | 15 ++ src/index.ts | 5 +- src/refresh/RefreshTimer.ts | 82 +++++++ src/requestTracing/utils.ts | 4 +- test/refresh.test.ts | 322 ++++++++++++++++++++++++++++ test/requestTracing.test.ts | 30 ++- test/utils/testHelper.ts | 23 +- 13 files changed, 780 insertions(+), 34 deletions(-) create mode 100644 examples/refresh.mjs create mode 100644 src/RefreshOptions.ts create mode 100644 src/WatchedSetting.ts create mode 100644 src/common/disposable.ts create mode 100644 src/refresh/RefreshTimer.ts create mode 100644 test/refresh.test.ts 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/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index a4af8746..4a90aaf8 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 9f6eb837..0b808a5f 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 978338d4..b4532804 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 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..88960137 --- /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 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/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts new file mode 100644 index 00000000..ac26f31b --- /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 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/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 616c7588..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; @@ -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 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 From e16bb8a61925cedb80eb571a2e64d0dee03ecd1b Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Wed, 31 Jan 2024 09:20:36 +0800 Subject: [PATCH 11/17] Add all-in-one pack script (#45) --- scripts/build-and-pack.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 scripts/build-and-pack.sh diff --git a/scripts/build-and-pack.sh b/scripts/build-and-pack.sh new file mode 100644 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 From ed6688af896bd6ae2b8ae17f05612fe478d08eb0 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Sun, 4 Feb 2024 16:13:16 +0800 Subject: [PATCH 12/17] chmod +x build-and-pack.sh (#47) --- scripts/build-and-pack.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/build-and-pack.sh diff --git a/scripts/build-and-pack.sh b/scripts/build-and-pack.sh old mode 100644 new mode 100755 From 02ded287262e075f8f658eb6795c5e7507841986 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Sat, 10 Feb 2024 08:15:13 +0800 Subject: [PATCH 13/17] Assert error messages and increase timeout for tests (#48) --- src/load.ts | 2 +- test/keyvault.test.ts | 6 ++++-- test/load.test.ts | 15 ++++++++++----- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/load.ts b/src/load.ts index 6e52b528..b98db7d4 100644 --- a/src/load.ts +++ b/src/load.ts @@ -48,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; } 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..e787962e 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -31,6 +31,8 @@ const mockedKVs = [{ }].map(createMockedKeyValue); describe("load", function () { + this.timeout(10000); + before(() => { mockAppConfigurationClientListConfigurationSettings(mockedKVs); }); @@ -65,12 +67,12 @@ 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 trim key prefix if applicable", async () => { @@ -117,7 +119,7 @@ describe("load", function () { 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 +127,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 () => { From 45c826648d3b50d7b152a3f0d1196a05bf275b10 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Mon, 26 Feb 2024 10:03:52 +0800 Subject: [PATCH 14/17] rename members to adopt private properties (#46) --- src/AzureAppConfigurationImpl.ts | 2 +- src/JsonKeyValueAdapter.ts | 9 ++-- src/common/disposable.ts | 15 +++--- src/keyvault/AzureKeyVaultKeyValueAdapter.ts | 39 +++++++------- src/refresh/RefreshTimer.ts | 53 +++++++++++--------- 5 files changed, 62 insertions(+), 56 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 0b808a5f..3cce9ab5 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -176,7 +176,7 @@ export class AzureAppConfigurationImpl extends Map implements Azure /** * Refresh the configuration store. */ - public async refresh(): Promise { + async refresh(): Promise { if (!this.#refreshEnabled) { throw new Error("Refresh is not enabled."); } 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/common/disposable.ts b/src/common/disposable.ts index 88960137..4843e121 100644 --- a/src/common/disposable.ts +++ b/src/common/disposable.ts @@ -2,14 +2,17 @@ // Licensed under the MIT license. export class Disposable { - private disposed = false; - constructor(private callOnDispose: () => any) { } + #disposed = false; + #callOnDispose: () => any; + + constructor(callOnDispose: () => any) { + this.#callOnDispose = callOnDispose; + } dispose() { - if (!this.disposed) { - this.callOnDispose(); + if (!this.#disposed) { + this.#callOnDispose(); } - this.disposed = true; + this.#disposed = true; } - } \ No newline at end of file diff --git a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts index b7025d58..b65a0314 100644 --- a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -10,19 +10,20 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { /** * Map vault hostname to corresponding secret client. */ - private secretClients: Map; + #secretClients: Map; + #keyVaultOptions: KeyVaultOptions | undefined; - constructor( - private keyVaultOptions: KeyVaultOptions | 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/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts index ac26f31b..3ae824b1 100644 --- a/src/refresh/RefreshTimer.ts +++ b/src/refresh/RefreshTimer.ts @@ -22,52 +22,55 @@ const MaxSafeExponential = 30; // Used to avoid overflow. bitwise operations in const JitterRatio = 0.25; export class RefreshTimer { - private _minBackoff: number = MinimumBackoffInMs; - private _maxBackoff: number = MaximumBackoffInMs; - private _failedAttempts: number = 0; - private _backoffEnd: number; // Timestamp + #minBackoff: number = MinimumBackoffInMs; + #maxBackoff: number = MaximumBackoffInMs; + #failedAttempts: number = 0; + #backoffEnd: number; // Timestamp + #interval: number; + constructor( - private _interval: number + interval: number ) { - if (this._interval <= 0) { - throw new Error(`Refresh interval must be greater than 0. Given: ${this._interval}`); + if (interval <= 0) { + throw new Error(`Refresh interval must be greater than 0. Given: ${this.#interval}`); } - this._backoffEnd = Date.now() + this._interval; + this.#interval = interval; + this.#backoffEnd = Date.now() + this.#interval; } - public canRefresh(): boolean { - return Date.now() >= this._backoffEnd; + canRefresh(): boolean { + return Date.now() >= this.#backoffEnd; } - public backoff(): void { - this._failedAttempts += 1; - this._backoffEnd = Date.now() + this._calculateBackoffTime(); + backoff(): void { + this.#failedAttempts += 1; + this.#backoffEnd = Date.now() + this.#calculateBackoffTime(); } - public reset(): void { - this._failedAttempts = 0; - this._backoffEnd = Date.now() + this._interval; + reset(): void { + this.#failedAttempts = 0; + this.#backoffEnd = Date.now() + this.#interval; } - private _calculateBackoffTime(): number { + #calculateBackoffTime(): number { let minBackoffMs: number; let maxBackoffMs: number; - if (this._interval <= this._minBackoff) { - return this._interval; + if (this.#interval <= this.#minBackoff) { + return this.#interval; } // _minBackoff <= _interval - if (this._interval <= this._maxBackoff) { - minBackoffMs = this._minBackoff; - maxBackoffMs = this._interval; + if (this.#interval <= this.#maxBackoff) { + minBackoffMs = this.#minBackoff; + maxBackoffMs = this.#interval; } else { - minBackoffMs = this._minBackoff; - maxBackoffMs = this._maxBackoff; + minBackoffMs = this.#minBackoff; + maxBackoffMs = this.#maxBackoff; } // exponential: minBackoffMs * 2^(failedAttempts-1) - const exponential = Math.min(this._failedAttempts - 1, MaxSafeExponential); + const exponential = Math.min(this.#failedAttempts - 1, MaxSafeExponential); let calculatedBackoffMs = minBackoffMs * (1 << exponential); if (calculatedBackoffMs > maxBackoffMs) { calculatedBackoffMs = maxBackoffMs; From 28a5c6900dbe8eeb6adcca930e219095166ccb33 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Mon, 18 Mar 2024 12:28:18 +0800 Subject: [PATCH 15/17] Refactor AzureAppConfiguration with new interfaces (#49) --- src/AzureAppConfiguration.ts | 27 ++++- src/AzureAppConfigurationImpl.ts | 61 +++++++++-- test/json.test.ts | 6 +- test/load.test.ts | 177 +++++++++++++++++++++++++++++-- 4 files changed, 250 insertions(+), 21 deletions(-) diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index 4a90aaf8..7b813a85 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -16,4 +16,29 @@ export type AzureAppConfiguration = { * @param thisArg - Optional. Value to use as `this` when executing callback. */ onRefresh(listener: () => any, thisArg?: any): Disposable; -} & ReadonlyMap; +} & IGettable & 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 3cce9ab5..aac87943 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -3,7 +3,7 @@ import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions } from "@azure/app-configuration"; import { RestError } from "@azure/core-rest-pipeline"; -import { AzureAppConfiguration } from "./AzureAppConfiguration"; +import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions"; import { IKeyValueAdapter } from "./IKeyValueAdapter"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter"; @@ -15,7 +15,12 @@ import { CorrelationContextHeaderName } from "./requestTracing/constants"; import { createCorrelationContextHeader, requestTracingEnabled } from "./requestTracing/utils"; import { KeyFilter, LabelFilter, SettingSelector } from "./types"; -export class AzureAppConfigurationImpl extends Map implements AzureAppConfiguration { +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. @@ -40,7 +45,6 @@ export class AzureAppConfigurationImpl extends Map implements Azure client: AppConfigurationClient, options: AzureAppConfigurationOptions | undefined ) { - super(); this.#client = client; this.#options = options; @@ -87,6 +91,9 @@ export class AzureAppConfigurationImpl extends Map implements Azure this.#adapters.push(new JsonKeyValueAdapter()); } + get(key: string): T | undefined { + return this.#configMap.get(key); + } get #refreshEnabled(): boolean { return !!this.#options?.refreshOptions?.enabled; @@ -157,9 +164,9 @@ export class AzureAppConfigurationImpl extends Map implements Azure keyValues.push([key, value]); } - this.clear(); // clear existing key-values in case of configuration setting deletion + 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); } } @@ -168,11 +175,53 @@ export class AzureAppConfigurationImpl extends Map implements Azure */ 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; + } + /** * Refresh the configuration store. */ 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/load.test.ts b/test/load.test.ts index e787962e..ecb356bb 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,7 +40,30 @@ 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); @@ -85,11 +120,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 () => { @@ -102,20 +134,15 @@ 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(""); }); @@ -153,7 +180,6 @@ describe("load", function () { }] }); expect(settings).not.undefined; - expect(settings.has("TestKey")).eq(true); expect(settings.get("TestKey")).eq("TestValueForProd"); }); @@ -172,8 +198,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: '.', ',', ';', '-', '_', '__', '/', ':'."); + }); }); From 87b0dbc12feb86b67bdf7d17f8301349dd105d05 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Thu, 21 Mar 2024 09:54:05 +0800 Subject: [PATCH 16/17] Add back ReadonlyMap APIs (#52) --- src/AzureAppConfiguration.ts | 2 +- src/AzureAppConfigurationImpl.ts | 29 ++++++++++++++ test/load.test.ts | 68 ++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index 7b813a85..716a60f7 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -16,7 +16,7 @@ export type AzureAppConfiguration = { * @param thisArg - Optional. Value to use as `this` when executing callback. */ onRefresh(listener: () => any, thisArg?: any): Disposable; -} & IGettable & IConfigurationObject; +} & IGettable & ReadonlyMap & IConfigurationObject; interface IConfigurationObject { /** diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index aac87943..41e66a62 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -91,10 +91,39 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#adapters.push(new JsonKeyValueAdapter()); } + // 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; } diff --git a/test/load.test.ts b/test/load.test.ts index ecb356bb..63f1cfd1 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -110,6 +110,74 @@ describe("load", function () { 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 () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { From cd5552afd9c0fb5468e1949b9f02163b06d40881 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Thu, 21 Mar 2024 10:04:40 +0800 Subject: [PATCH 17/17] Add example for configuration object usage (#53) --- examples/configObject.mjs | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 examples/configObject.mjs 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}`);