From 64caf1f141ce54f90d18577843720ea2abf32230 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Fri, 25 Apr 2025 14:15:39 +0800 Subject: [PATCH 1/9] replace rightshift with math.pow (#189) --- src/ConfigurationClientWrapper.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ConfigurationClientWrapper.ts b/src/ConfigurationClientWrapper.ts index 7dd6f418..967158ae 100644 --- a/src/ConfigurationClientWrapper.ts +++ b/src/ConfigurationClientWrapper.ts @@ -5,7 +5,6 @@ import { AppConfigurationClient } from "@azure/app-configuration"; const MaxBackoffDuration = 10 * 60 * 1000; // 10 minutes in milliseconds const MinBackoffDuration = 30 * 1000; // 30 seconds in milliseconds -const MAX_SAFE_EXPONENTIAL = 30; // Used to avoid overflow. bitwise operations in JavaScript are limited to 32 bits. It overflows at 2^31 - 1. const JITTER_RATIO = 0.25; export class ConfigurationClientWrapper { @@ -36,8 +35,8 @@ export function calculateBackoffDuration(failedAttempts: number) { } // exponential: minBackoff * 2 ^ (failedAttempts - 1) - const exponential = Math.min(failedAttempts - 1, MAX_SAFE_EXPONENTIAL); - let calculatedBackoffDuration = MinBackoffDuration * (1 << exponential); + // The right shift operator is not used in order to avoid potential overflow. Bitwise operations in JavaScript are limited to 32 bits. + let calculatedBackoffDuration = MinBackoffDuration * Math.pow(2, failedAttempts - 1); if (calculatedBackoffDuration > MaxBackoffDuration) { calculatedBackoffDuration = MaxBackoffDuration; } From 5c1f4a34475fe94326228f8bdffaa8088779a2d6 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Tue, 29 Apr 2025 10:35:27 +0800 Subject: [PATCH 2/9] Startup Retry & Configurable Startup Time-out & Error handling (#166) * support startup retry and timeout * update * update * update * add testcase * clarify error type * update * update * update * fix lint * handle keyvault error * update * update * update * update * update * update * handle keyvault reference error * update * fix lint * update * update * add boot loop protection * update * update * update testcase * update * update testcase * update * update * update * move error.ts to common folder * handle transient network error * update * update * keep error stack when fail to load * update testcase --- rollup.config.mjs | 10 +- src/AzureAppConfiguration.ts | 3 + src/AzureAppConfigurationImpl.ts | 119 +++++++++++---- src/AzureAppConfigurationOptions.ts | 11 +- src/ConfigurationClientManager.ts | 44 ++---- src/ConfigurationClientWrapper.ts | 25 +--- src/StartupOptions.ts | 14 ++ src/common/backoffUtils.ts | 37 +++++ src/common/error.ts | 62 ++++++++ src/common/utils.ts | 4 + src/featureManagement/FeatureFlagOptions.ts | 2 +- src/keyvault/AzureKeyVaultKeyValueAdapter.ts | 63 +++++--- src/load.ts | 5 +- src/refresh/RefreshTimer.ts | 6 +- .../refreshOptions.ts} | 88 +++++------ test/clientOptions.test.ts | 9 ++ test/failover.test.ts | 8 - test/keyvault.test.ts | 34 +++-- test/load.test.ts | 6 +- test/requestTracing.test.ts | 138 ++++++++++++++---- test/startup.test.ts | 113 ++++++++++++++ 21 files changed, 600 insertions(+), 201 deletions(-) create mode 100644 src/StartupOptions.ts create mode 100644 src/common/backoffUtils.ts create mode 100644 src/common/error.ts rename src/{RefreshOptions.ts => refresh/refreshOptions.ts} (94%) create mode 100644 test/startup.test.ts diff --git a/rollup.config.mjs b/rollup.config.mjs index 1fa9626f..6f78ca44 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -4,7 +4,15 @@ import dts from "rollup-plugin-dts"; export default [ { - external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline", "crypto", "dns/promises", "@microsoft/feature-management"], + external: [ + "@azure/app-configuration", + "@azure/keyvault-secrets", + "@azure/core-rest-pipeline", + "@azure/identity", + "crypto", + "dns/promises", + "@microsoft/feature-management" + ], input: "src/index.ts", output: [ { diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index 3f2918be..dbe2ce48 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -3,6 +3,9 @@ import { Disposable } from "./common/disposable.js"; +/** + * Azure App Configuration provider. + */ export type AzureAppConfiguration = { /** * API to trigger refresh operation. diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 35fc26ee..fb523ab0 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -7,7 +7,8 @@ import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from ". import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; -import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions.js"; +import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js"; +import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js"; import { Disposable } from "./common/disposable.js"; import { FEATURE_FLAGS_KEY_NAME, @@ -33,6 +34,10 @@ import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOp import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; +import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; +import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/error.js"; + +const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds type PagedSettingSelector = SettingSelector & { /** @@ -118,10 +123,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } else { 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."); + throw new ArgumentError("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."); + throw new ArgumentError("The characters '*' and ',' are not supported in label of watched settings."); } this.#sentinels.push(setting); } @@ -130,7 +135,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // custom refresh interval if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { - throw new Error(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); + throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); } else { this.#kvRefreshInterval = refreshIntervalInMs; } @@ -148,7 +153,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // custom refresh interval if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { - throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); + throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); } else { this.#ffRefreshInterval = refreshIntervalInMs; } @@ -225,13 +230,40 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Loads the configuration store for the first time. */ async load() { - await this.#inspectFmPackage(); - await this.#loadSelectedAndWatchedKeyValues(); - if (this.#featureFlagEnabled) { - await this.#loadFeatureFlags(); + const startTimestamp = Date.now(); + const startupTimeout: number = this.#options?.startupOptions?.timeoutInMs ?? DEFAULT_STARTUP_TIMEOUT_IN_MS; + const abortController = new AbortController(); + const abortSignal = abortController.signal; + let timeoutId; + try { + // Promise.race will be settled when the first promise in the list is settled. + // It will not cancel the remaining promises in the list. + // To avoid memory leaks, we must ensure other promises will be eventually terminated. + await Promise.race([ + this.#initializeWithRetryPolicy(abortSignal), + // this promise will be rejected after timeout + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + abortController.abort(); // abort the initialization promise + reject(new Error("Load operation timed out.")); + }, + startupTimeout); + }) + ]); + } catch (error) { + if (!isInputError(error)) { + const timeElapsed = Date.now() - startTimestamp; + if (timeElapsed < MIN_DELAY_FOR_UNHANDLED_FAILURE) { + // 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. + await new Promise(resolve => setTimeout(resolve, MIN_DELAY_FOR_UNHANDLED_FAILURE - timeElapsed)); + } + } + throw new Error("Failed to load.", { cause: error }); + } finally { + clearTimeout(timeoutId); // cancel the timeout promise } - // Mark all settings have loaded at startup. - this.#isInitialLoadCompleted = true; } /** @@ -241,7 +273,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const separator = options?.separator ?? "."; const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"]; if (!validSeparators.includes(separator)) { - throw new Error(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`); + throw new ArgumentError(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`); } // construct hierarchical data object from map @@ -254,7 +286,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const segment = segments[i]; // undefined or empty string if (!segment) { - throw new Error(`invalid key: ${key}`); + throw new InvalidOperationError(`Failed to construct configuration object: Invalid key: ${key}`); } // create path if not exist if (current[segment] === undefined) { @@ -262,14 +294,14 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } // 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.`); + throw new InvalidOperationError(`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.`); + throw new InvalidOperationError(`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; @@ -282,7 +314,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async refresh(): Promise { if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new Error("Refresh is not enabled for key-values or feature flags."); + throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); } if (this.#refreshInProgress) { @@ -301,7 +333,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ onRefresh(listener: () => any, thisArg?: any): Disposable { if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new Error("Refresh is not enabled for key-values or feature flags."); + throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); } const boundedListener = listener.bind(thisArg); @@ -316,6 +348,42 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return new Disposable(remove); } + /** + * Initializes the configuration provider. + */ + async #initializeWithRetryPolicy(abortSignal: AbortSignal): Promise { + if (!this.#isInitialLoadCompleted) { + await this.#inspectFmPackage(); + const startTimestamp = Date.now(); + let postAttempts = 0; + do { // at least try to load once + try { + await this.#loadSelectedAndWatchedKeyValues(); + if (this.#featureFlagEnabled) { + await this.#loadFeatureFlags(); + } + this.#isInitialLoadCompleted = true; + break; + } catch (error) { + if (isInputError(error)) { + throw error; + } + if (abortSignal.aborted) { + return; + } + const timeElapsed = Date.now() - startTimestamp; + let backoffDuration = getFixedBackoffDuration(timeElapsed); + if (backoffDuration === undefined) { + postAttempts += 1; + backoffDuration = getExponentialBackoffDuration(postAttempts); + } + console.warn(`Failed to load. Error message: ${error.message}. Retrying in ${backoffDuration} ms.`); + await new Promise(resolve => setTimeout(resolve, backoffDuration)); + } + } while (!abortSignal.aborted); + } + } + /** * Inspects the feature management package version. */ @@ -426,7 +494,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#aiConfigurationTracing.reset(); } - // process key-values, watched settings have higher priority + // adapt configuration settings to key-values for (const setting of loadedSettings) { const [key, value] = await this.#processKeyValue(setting); keyValues.push([key, value]); @@ -606,6 +674,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return response; } + // Only operations related to Azure App Configuration should be executed with failover policy. async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { let clientWrappers = await this.#clientManager.getClients(); if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) { @@ -645,7 +714,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } this.#clientManager.refreshClients(); - throw new Error("All clients failed to get configuration settings."); + throw new Error("All fallback clients failed to get configuration settings."); } async #processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { @@ -700,7 +769,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { async #parseFeatureFlag(setting: ConfigurationSetting): Promise { const rawFlag = setting.value; if (rawFlag === undefined) { - throw new Error("The value of configuration setting cannot be undefined."); + throw new ArgumentError("The value of configuration setting cannot be undefined."); } const featureFlag = JSON.parse(rawFlag); @@ -762,13 +831,13 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { return uniqueSelectors.map(selectorCandidate => { const selector = { ...selectorCandidate }; if (!selector.keyFilter) { - throw new Error("Key filter cannot be null or empty."); + throw new ArgumentError("Key filter cannot be null or empty."); } if (!selector.labelFilter) { selector.labelFilter = LabelFilter.Null; } if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in label filters."); + throw new ArgumentError("The characters '*' and ',' are not supported in label filters."); } return selector; }); @@ -792,9 +861,3 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel }); return getValidSelectors(selectors); } - -function isFailoverableError(error: any): boolean { - // ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory - return isRestError(error) && (error.code === "ENOTFOUND" || error.code === "ENOENT" || - (error.statusCode !== undefined && (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500))); -} diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 56b47b50..dcf27765 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -3,12 +3,10 @@ import { AppConfigurationClientOptions } from "@azure/app-configuration"; import { KeyVaultOptions } from "./keyvault/KeyVaultOptions.js"; -import { RefreshOptions } from "./RefreshOptions.js"; +import { RefreshOptions } from "./refresh/refreshOptions.js"; import { SettingSelector } from "./types.js"; import { FeatureFlagOptions } from "./featureManagement/FeatureFlagOptions.js"; - -export const MaxRetries = 2; -export const MaxRetryDelayInMs = 60000; +import { StartupOptions } from "./StartupOptions.js"; export interface AzureAppConfigurationOptions { /** @@ -48,6 +46,11 @@ export interface AzureAppConfigurationOptions { */ featureFlagOptions?: FeatureFlagOptions; + /** + * Specifies options used to configure provider startup. + */ + startupOptions?: StartupOptions; + /** * Specifies whether to enable replica discovery or not. * diff --git a/src/ConfigurationClientManager.ts b/src/ConfigurationClientManager.ts index 7e5151a4..72a3bfeb 100644 --- a/src/ConfigurationClientManager.ts +++ b/src/ConfigurationClientManager.ts @@ -4,10 +4,15 @@ import { AppConfigurationClient, AppConfigurationClientOptions } from "@azure/app-configuration"; import { ConfigurationClientWrapper } from "./ConfigurationClientWrapper.js"; import { TokenCredential } from "@azure/identity"; -import { AzureAppConfigurationOptions, MaxRetries, MaxRetryDelayInMs } from "./AzureAppConfigurationOptions.js"; +import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; import { isBrowser, isWebWorker } from "./requestTracing/utils.js"; import * as RequestTracing from "./requestTracing/constants.js"; -import { shuffleList } from "./common/utils.js"; +import { shuffleList, instanceOfTokenCredential } from "./common/utils.js"; +import { ArgumentError } from "./common/error.js"; + +// Configuration client retry options +const CLIENT_MAX_RETRIES = 2; +const CLIENT_MAX_RETRY_DELAY = 60_000; // 1 minute in milliseconds const TCP_ORIGIN_KEY_NAME = "_origin._tcp"; const ALT_KEY_NAME = "_alt"; @@ -54,18 +59,18 @@ export class ConfigurationClientManager { const regexMatch = connectionString.match(ConnectionStringRegex); if (regexMatch) { const endpointFromConnectionStr = regexMatch[1]; - this.endpoint = getValidUrl(endpointFromConnectionStr); + this.endpoint = new URL(endpointFromConnectionStr); this.#id = regexMatch[2]; this.#secret = regexMatch[3]; } else { - throw new Error(`Invalid connection string. Valid connection strings should match the regex '${ConnectionStringRegex.source}'.`); + throw new ArgumentError(`Invalid connection string. Valid connection strings should match the regex '${ConnectionStringRegex.source}'.`); } staticClient = new AppConfigurationClient(connectionString, this.#clientOptions); } else if ((connectionStringOrEndpoint instanceof URL || typeof connectionStringOrEndpoint === "string") && credentialPassed) { let endpoint = connectionStringOrEndpoint; // ensure string is a valid URL. if (typeof endpoint === "string") { - endpoint = getValidUrl(endpoint); + endpoint = new URL(endpoint); } const credential = credentialOrOptions as TokenCredential; @@ -75,7 +80,7 @@ export class ConfigurationClientManager { this.#credential = credential; staticClient = new AppConfigurationClient(this.endpoint.origin, this.#credential, this.#clientOptions); } else { - throw new Error("A connection string or an endpoint with credential must be specified to create a client."); + throw new ArgumentError("A connection string or an endpoint with credential must be specified to create a client."); } this.#staticClients = [new ConfigurationClientWrapper(this.endpoint.origin, staticClient)]; @@ -200,12 +205,12 @@ export class ConfigurationClientManager { }); index++; } - } catch (err) { - if (err.code === "ENOTFOUND") { + } catch (error) { + if (error.code === "ENOTFOUND") { // No more SRV records found, return results. return results; } else { - throw new Error(`Failed to lookup SRV records: ${err.message}`); + throw new Error(`Failed to lookup SRV records: ${error.message}`); } } @@ -260,8 +265,8 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat // retry options const defaultRetryOptions = { - maxRetries: MaxRetries, - maxRetryDelayInMs: MaxRetryDelayInMs, + maxRetries: CLIENT_MAX_RETRIES, + maxRetryDelayInMs: CLIENT_MAX_RETRY_DELAY, }; const retryOptions = Object.assign({}, defaultRetryOptions, options?.clientOptions?.retryOptions); @@ -272,20 +277,3 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat } }); } - -function getValidUrl(endpoint: string): URL { - try { - return new URL(endpoint); - } catch (error) { - if (error.code === "ERR_INVALID_URL") { - throw new Error("Invalid endpoint URL.", { cause: error }); - } else { - throw error; - } - } -} - -export function instanceOfTokenCredential(obj: unknown) { - return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function"; -} - diff --git a/src/ConfigurationClientWrapper.ts b/src/ConfigurationClientWrapper.ts index 967158ae..137d1c38 100644 --- a/src/ConfigurationClientWrapper.ts +++ b/src/ConfigurationClientWrapper.ts @@ -2,10 +2,7 @@ // Licensed under the MIT license. import { AppConfigurationClient } from "@azure/app-configuration"; - -const MaxBackoffDuration = 10 * 60 * 1000; // 10 minutes in milliseconds -const MinBackoffDuration = 30 * 1000; // 30 seconds in milliseconds -const JITTER_RATIO = 0.25; +import { getExponentialBackoffDuration } from "./common/backoffUtils.js"; export class ConfigurationClientWrapper { endpoint: string; @@ -24,25 +21,7 @@ export class ConfigurationClientWrapper { this.backoffEndTime = Date.now(); } else { this.#failedAttempts += 1; - this.backoffEndTime = Date.now() + calculateBackoffDuration(this.#failedAttempts); + this.backoffEndTime = Date.now() + getExponentialBackoffDuration(this.#failedAttempts); } } } - -export function calculateBackoffDuration(failedAttempts: number) { - if (failedAttempts <= 1) { - return MinBackoffDuration; - } - - // exponential: minBackoff * 2 ^ (failedAttempts - 1) - // The right shift operator is not used in order to avoid potential overflow. Bitwise operations in JavaScript are limited to 32 bits. - let calculatedBackoffDuration = MinBackoffDuration * Math.pow(2, failedAttempts - 1); - if (calculatedBackoffDuration > MaxBackoffDuration) { - calculatedBackoffDuration = MaxBackoffDuration; - } - - // jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs - const jitter = JITTER_RATIO * (Math.random() * 2 - 1); - - return calculatedBackoffDuration * (1 + jitter); -} diff --git a/src/StartupOptions.ts b/src/StartupOptions.ts new file mode 100644 index 00000000..f80644bb --- /dev/null +++ b/src/StartupOptions.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100 * 1000; // 100 seconds in milliseconds + +export interface StartupOptions { + /** + * The amount of time allowed to load data from Azure App Configuration on startup. + * + * @remarks + * If not specified, the default value is 100 seconds. + */ + timeoutInMs?: number; +} diff --git a/src/common/backoffUtils.ts b/src/common/backoffUtils.ts new file mode 100644 index 00000000..2bebf5c4 --- /dev/null +++ b/src/common/backoffUtils.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const MIN_BACKOFF_DURATION = 30_000; // 30 seconds in milliseconds +const MAX_BACKOFF_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds +const JITTER_RATIO = 0.25; + +export function getFixedBackoffDuration(timeElapsedInMs: number): number | undefined { + if (timeElapsedInMs < 100_000) { + return 5_000; + } + if (timeElapsedInMs < 200_000) { + return 10_000; + } + if (timeElapsedInMs < 10 * 60 * 1000) { + return MIN_BACKOFF_DURATION; + } + return undefined; +} + +export function getExponentialBackoffDuration(failedAttempts: number): number { + if (failedAttempts <= 1) { + return MIN_BACKOFF_DURATION; + } + + // exponential: minBackoff * 2 ^ (failedAttempts - 1) + // The right shift operator is not used in order to avoid potential overflow. Bitwise operations in JavaScript are limited to 32 bits. + let calculatedBackoffDuration = MIN_BACKOFF_DURATION * Math.pow(2, failedAttempts - 1); + if (calculatedBackoffDuration > MAX_BACKOFF_DURATION) { + calculatedBackoffDuration = MAX_BACKOFF_DURATION; + } + + // jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs + const jitter = JITTER_RATIO * (Math.random() * 2 - 1); + + return calculatedBackoffDuration * (1 + jitter); +} diff --git a/src/common/error.ts b/src/common/error.ts new file mode 100644 index 00000000..bd4f5adf --- /dev/null +++ b/src/common/error.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { isRestError } from "@azure/core-rest-pipeline"; + +/** + * Error thrown when an operation cannot be performed by the Azure App Configuration provider. + */ +export class InvalidOperationError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidOperationError"; + } +} + +/** + * Error thrown when an input argument is invalid. + */ +export class ArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = "ArgumentError"; + } +} + +/** + * Error thrown when a Key Vault reference cannot be resolved. + */ +export class KeyVaultReferenceError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "KeyVaultReferenceError"; + } +} + +export function isFailoverableError(error: any): boolean { + if (!isRestError(error)) { + return false; + } + // https://nodejs.org/api/errors.html#common-system-errors + // ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory, ECONNREFUSED: connection refused, ECONNRESET: connection reset by peer, ETIMEDOUT: connection timed out + if (error.code !== undefined && + (error.code === "ENOTFOUND" || error.code === "ENOENT" || error.code === "ECONNREFUSED" || error.code === "ECONNRESET" || error.code === "ETIMEDOUT")) { + return true; + } + // 401 Unauthorized, 403 Forbidden, 408 Request Timeout, 429 Too Many Requests, 5xx Server Errors + if (error.statusCode !== undefined && + (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500)) { + return true; + } + + return false; +} + +/** + * Check if the error is an instance of ArgumentError, TypeError, or RangeError. + */ +export function isInputError(error: any): boolean { + return error instanceof ArgumentError || + error instanceof TypeError || + error instanceof RangeError; +} diff --git a/src/common/utils.ts b/src/common/utils.ts index 2db9e65a..18667874 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -8,3 +8,7 @@ export function shuffleList(array: T[]): T[] { } return array; } + +export function instanceOfTokenCredential(obj: unknown) { + return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function"; +} diff --git a/src/featureManagement/FeatureFlagOptions.ts b/src/featureManagement/FeatureFlagOptions.ts index 55ceda4d..6814dbf3 100644 --- a/src/featureManagement/FeatureFlagOptions.ts +++ b/src/featureManagement/FeatureFlagOptions.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { FeatureFlagRefreshOptions } from "../RefreshOptions.js"; +import { FeatureFlagRefreshOptions } from "../refresh/refreshOptions.js"; import { SettingSelector } from "../types.js"; /** diff --git a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts index 1b6fdcc4..1b8c5977 100644 --- a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -4,12 +4,15 @@ import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration"; import { IKeyValueAdapter } from "../IKeyValueAdapter.js"; import { KeyVaultOptions } from "./KeyVaultOptions.js"; +import { ArgumentError, KeyVaultReferenceError } from "../common/error.js"; import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; +import { isRestError } from "@azure/core-rest-pipeline"; +import { AuthenticationError } from "@azure/identity"; export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { /** * Map vault hostname to corresponding secret client. - */ + */ #secretClients: Map; #keyVaultOptions: KeyVaultOptions | undefined; @@ -24,33 +27,53 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { // TODO: cache results to save requests. if (!this.#keyVaultOptions) { - throw new Error("Configure keyVaultOptions to resolve Key Vault Reference(s)."); + throw new ArgumentError("Failed to process the Key Vault reference because Key Vault options are not configured."); } - - // precedence: secret clients > credential > secret resolver - const { name: secretName, vaultUrl, sourceId, version } = parseKeyVaultSecretIdentifier( - parseSecretReference(setting).value.secretId - ); - - 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]; + let secretName, vaultUrl, sourceId, version; + try { + const { name: parsedName, vaultUrl: parsedVaultUrl, sourceId: parsedSourceId, version: parsedVersion } = parseKeyVaultSecretIdentifier( + parseSecretReference(setting).value.secretId + ); + secretName = parsedName; + vaultUrl = parsedVaultUrl; + sourceId = parsedSourceId; + version = parsedVersion; + } catch (error) { + throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", setting), { cause: error }); } - if (this.#keyVaultOptions.secretResolver) { - return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(sourceId))]; + try { + // precedence: secret clients > credential > secret resolver + const client = this.#getSecretClient(new URL(vaultUrl)); + if (client) { + 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))]; + } + } catch (error) { + if (isRestError(error) || error instanceof AuthenticationError) { + throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", setting, sourceId), { cause: error }); + } + throw error; } - throw new Error("No key vault credential or secret resolver callback configured, and no matching secret client could be found."); + // When code reaches here, it means that the key vault reference cannot be resolved in all possible ways. + throw new ArgumentError("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); } + /** + * + * @param vaultUrl - The url of the key vault. + * @returns + */ #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); + for (const client of this.#keyVaultOptions?.secretClients ?? []) { + const clientUrl = new URL(client.vaultUrl); + this.#secretClients.set(clientUrl.host, client); } } @@ -70,6 +93,6 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { } } -function getHost(url: string) { - return new URL(url).host; +function buildKeyVaultReferenceErrorMessage(message: string, setting: ConfigurationSetting, secretIdentifier?: string ): string { + return `${message} Key: '${setting.key}' Label: '${setting.label ?? ""}' ETag: '${setting.etag ?? ""}' ${secretIdentifier ? ` SecretIdentifier: '${secretIdentifier}'` : ""}`; } diff --git a/src/load.ts b/src/load.ts index 4d24174e..2046b064 100644 --- a/src/load.ts +++ b/src/load.ts @@ -5,9 +5,10 @@ import { TokenCredential } from "@azure/identity"; import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; -import { ConfigurationClientManager, instanceOfTokenCredential } from "./ConfigurationClientManager.js"; +import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; +import { instanceOfTokenCredential } from "./common/utils.js"; -const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5000; // 5 seconds +const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5_000; // 5 seconds /** * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. diff --git a/src/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts index 45fdf0b3..5dff67fd 100644 --- a/src/refresh/RefreshTimer.ts +++ b/src/refresh/RefreshTimer.ts @@ -5,11 +5,9 @@ export class RefreshTimer { #backoffEnd: number; // Timestamp #interval: number; - constructor( - interval: number - ) { + constructor(interval: number) { if (interval <= 0) { - throw new Error(`Refresh interval must be greater than 0. Given: ${this.#interval}`); + throw new RangeError(`Refresh interval must be greater than 0. Given: ${interval}`); } this.#interval = interval; diff --git a/src/RefreshOptions.ts b/src/refresh/refreshOptions.ts similarity index 94% rename from src/RefreshOptions.ts rename to src/refresh/refreshOptions.ts index d5e4da5f..202c7340 100644 --- a/src/RefreshOptions.ts +++ b/src/refresh/refreshOptions.ts @@ -1,44 +1,44 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { WatchedSetting } from "./WatchedSetting.js"; - -export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30 * 1000; -export const MIN_REFRESH_INTERVAL_IN_MS = 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 when refresh() is called. - * - * @remarks - * If no watched setting is specified, all configuration settings will be watched. - */ - watchedSettings?: WatchedSetting[]; -} - -export interface FeatureFlagRefreshOptions { - /** - * Specifies whether the provider should automatically refresh all feature flags if any feature flag changes. - */ - enabled: boolean; - - /** - * Specifies the minimum time that must elapse before checking the server for any new changes. - * Default value is 30 seconds. Must be greater than 1 second. - * Any refresh operation triggered will not update the value for a key until after the interval. - */ - refreshIntervalInMs?: number; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WatchedSetting } from "../WatchedSetting.js"; + +export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30 * 1000; +export const MIN_REFRESH_INTERVAL_IN_MS = 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 when refresh() is called. + * + * @remarks + * If no watched setting is specified, all configuration settings will be watched. + */ + watchedSettings?: WatchedSetting[]; +} + +export interface FeatureFlagRefreshOptions { + /** + * Specifies whether the provider should automatically refresh all feature flags if any feature flag changes. + */ + enabled: boolean; + + /** + * Specifies the minimum time that must elapse before checking the server for any new changes. + * Default value is 30 seconds. Must be greater than 1 second. + * Any refresh operation triggered will not update the value for a key until after the interval. + */ + refreshIntervalInMs?: number; +} diff --git a/test/clientOptions.test.ts b/test/clientOptions.test.ts index 2e9417e9..3401c19a 100644 --- a/test/clientOptions.test.ts +++ b/test/clientOptions.test.ts @@ -48,6 +48,9 @@ describe("custom client options", function () { policy: countPolicy, position: "perRetry" }] + }, + startupOptions: { + timeoutInMs: 5_000 } }); }; @@ -73,6 +76,9 @@ describe("custom client options", function () { retryOptions: { maxRetries } + }, + startupOptions: { + timeoutInMs: 5_000 } }); }; @@ -108,6 +114,9 @@ describe("custom client options", function () { policy: countPolicy, position: "perRetry" }] + }, + startupOptions: { + timeoutInMs: 5_000 } }); }; diff --git a/test/failover.test.ts b/test/failover.test.ts index e1f2f043..e7b491d7 100644 --- a/test/failover.test.ts +++ b/test/failover.test.ts @@ -64,14 +64,6 @@ describe("failover", function () { expect(settings.get("feature_management").feature_flags).not.undefined; }); - it("should throw error when all clients failed", async () => { - const isFailoverable = false; - mockConfigurationManagerGetClients([], isFailoverable); - - const connectionString = createMockedConnectionString(); - return expect(load(connectionString)).eventually.rejectedWith("All clients failed to get configuration settings."); - }); - it("should validate endpoint", () => { const fakeHost = "fake.azconfig.io"; const validDomain = getValidDomain(fakeHost); diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index e88044ea..219a0bda 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -26,6 +26,7 @@ function mockNewlyCreatedKeyVaultSecretClients() { // eslint-disable-next-line @typescript-eslint/no-unused-vars mockSecretClientGetSecret(mockedData.map(([_key, secretUri, value]) => [secretUri, value])); } + describe("key vault reference", function () { this.timeout(MAX_TIME_OUT); @@ -39,7 +40,15 @@ describe("key vault reference", function () { }); it("require key vault options to resolve reference", async () => { - return expect(load(createMockedConnectionString())).eventually.rejectedWith("Configure keyVaultOptions to resolve Key Vault Reference(s)."); + try { + await load(createMockedConnectionString()); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Failed to process the Key Vault reference because Key Vault options are not configured."); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); }); it("should resolve key vault reference with credential", async () => { @@ -88,14 +97,21 @@ describe("key vault reference", function () { }); it("should throw error when secret clients not provided for all key vault references", async () => { - const loadKeyVaultPromise = load(createMockedConnectionString(), { - keyVaultOptions: { - secretClients: [ - new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), - ] - } - }); - return expect(loadKeyVaultPromise).eventually.rejectedWith("No key vault credential or secret resolver callback configured, and no matching secret client could be found."); + try { + await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [ + new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), + ] + } + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); }); 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 d36a3311..599392a4 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -114,12 +114,12 @@ describe("load", function () { }); it("should throw error given invalid connection string", async () => { - return expect(load("invalid-connection-string")).eventually.rejectedWith("Invalid connection string."); + return expect(load("invalid-connection-string")).eventually.rejectedWith("Invalid connection string"); }); it("should throw error given invalid endpoint URL", async () => { const credential = createMockedTokenCredential(); - return expect(load("invalid-endpoint-url", credential)).eventually.rejectedWith("Invalid endpoint URL."); + return expect(load("invalid-endpoint-url", credential)).eventually.rejectedWith("Invalid URL"); }); it("should not include feature flags directly in the settings", async () => { @@ -359,7 +359,7 @@ describe("load", function () { * When constructConfigurationObject() 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 () => { + it("Edge case 2: Hierarchical key-value pairs with overlapped key prefix.", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { selectors: [{ diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 3179602a..0b18f4b5 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -35,7 +35,12 @@ describe("request tracing", function () { it("should have correct user agent prefix", async () => { try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); @@ -43,7 +48,12 @@ describe("request tracing", function () { it("should have request type in correlation-context header", async () => { try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; expect(headerPolicy.headers.get("Correlation-Context")).eq("RequestType=Startup"); @@ -55,6 +65,9 @@ describe("request tracing", function () { clientOptions, keyVaultOptions: { credential: createMockedTokenCredential() + }, + startupOptions: { + timeoutInMs: 1 } }); } catch (e) { /* empty */ } @@ -69,6 +82,9 @@ describe("request tracing", function () { await load(createMockedConnectionString(fakeEndpoint), { clientOptions, loadBalancingEnabled: true, + startupOptions: { + timeoutInMs: 1 + } }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; @@ -81,7 +97,12 @@ describe("request tracing", function () { const replicaCount = 2; sinon.stub(ConfigurationClientManager.prototype, "getReplicaCount").returns(replicaCount); try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -93,7 +114,12 @@ describe("request tracing", function () { it("should detect env in correlation-context header", async () => { process.env.NODE_ENV = "development"; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -105,7 +131,12 @@ describe("request tracing", function () { it("should detect host type in correlation-context header", async () => { process.env.WEBSITE_SITE_NAME = "website-name"; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -118,7 +149,12 @@ describe("request tracing", function () { for (const indicator of ["TRUE", "true"]) { process.env.AZURE_APP_CONFIGURATION_TRACING_DISABLED = indicator; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -139,13 +175,13 @@ describe("request tracing", function () { clientOptions, refreshOptions: { enabled: true, - refreshIntervalInMs: 1000, + refreshIntervalInMs: 1_000, watchedSettings: [{ key: "app.settings.fontColor" }] } }); - await sleepInMs(1000 + 1); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); } catch (e) { /* empty */ } @@ -175,7 +211,7 @@ describe("request tracing", function () { selectors: [ {keyFilter: "*"} ], refresh: { enabled: true, - refreshIntervalInMs: 1000 + refreshIntervalInMs: 1_000 } } }); @@ -183,7 +219,7 @@ describe("request tracing", function () { expect(correlationContext).not.undefined; expect(correlationContext?.includes("RequestType=Startup")).eq(true); - await sleepInMs(1000 + 1); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); } catch (e) { /* empty */ } @@ -213,7 +249,7 @@ describe("request tracing", function () { selectors: [ {keyFilter: "*"} ], refresh: { enabled: true, - refreshIntervalInMs: 1000 + refreshIntervalInMs: 1_000 } } }); @@ -221,7 +257,7 @@ describe("request tracing", function () { expect(correlationContext).not.undefined; expect(correlationContext?.includes("RequestType=Startup")).eq(true); - await sleepInMs(1000 + 1); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); } catch (e) { /* empty */ } @@ -249,7 +285,7 @@ describe("request tracing", function () { selectors: [ {keyFilter: "*"} ], refresh: { enabled: true, - refreshIntervalInMs: 1000 + refreshIntervalInMs: 1_000 } } }); @@ -257,7 +293,7 @@ describe("request tracing", function () { expect(correlationContext).not.undefined; expect(correlationContext?.includes("RequestType=Startup")).eq(true); - await sleepInMs(1000 + 1); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); } catch (e) { /* empty */ } @@ -286,7 +322,7 @@ describe("request tracing", function () { selectors: [ {keyFilter: "*"} ], refresh: { enabled: true, - refreshIntervalInMs: 1000 + refreshIntervalInMs: 1_000 } } }); @@ -294,7 +330,7 @@ describe("request tracing", function () { expect(correlationContext).not.undefined; expect(correlationContext?.includes("RequestType=Startup")).eq(true); - await sleepInMs(1000 + 1); + await sleepInMs(1_000 + 1_000); try { await settings.refresh(); } catch (e) { /* empty */ } @@ -374,7 +410,12 @@ describe("request tracing", function () { (global as any).importScripts = function importScripts() { }; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -392,7 +433,12 @@ describe("request tracing", function () { (global as any).importScripts = function importScripts() { }; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -410,7 +456,12 @@ describe("request tracing", function () { (global as any).importScripts = function importScripts() { }; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -428,7 +479,12 @@ describe("request tracing", function () { (global as any).importScripts = function importScripts() { }; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -446,7 +502,12 @@ describe("request tracing", function () { (global as any).importScripts = undefined; try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -484,7 +545,12 @@ describe("request tracing", function () { (global as any).document = new (global as any).Document(); try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -499,7 +565,12 @@ describe("request tracing", function () { (global as any).document = undefined; // not an instance of Document try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -514,7 +585,12 @@ describe("request tracing", function () { (global as any).document = {}; // Not an instance of Document try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -529,7 +605,12 @@ describe("request tracing", function () { (global as any).document = new (global as any).Document(); try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -544,7 +625,12 @@ describe("request tracing", function () { (global as any).document = new (global as any).Document(); try { - await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); diff --git a/test/startup.test.ts b/test/startup.test.ts new file mode 100644 index 00000000..51b46a3a --- /dev/null +++ b/test/startup.test.ts @@ -0,0 +1,113 @@ +// 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 { MAX_TIME_OUT, createMockedConnectionString, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js"; + +describe("startup", function () { + this.timeout(MAX_TIME_OUT); + + afterEach(() => { + restoreMocks(); + }); + + it("should retry for load operation before timeout", async () => { + let attempt = 0; + const failForInitialAttempt = () => { + attempt += 1; + if (attempt <= 1) { + throw new Error("Test Error"); + } + }; + mockAppConfigurationClientListConfigurationSettings( + [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)], + failForInitialAttempt); + + const settings = await load(createMockedConnectionString()); + expect(attempt).eq(2); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("TestValue"); + }); + + it("should not retry for load operation after timeout", async () => { + let attempt = 0; + const failForAllAttempts = () => { + attempt += 1; + throw new Error("Test Error"); + }; + mockAppConfigurationClientListConfigurationSettings( + [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)], + failForAllAttempts); + + try { + await load(createMockedConnectionString(), { + startupOptions: { + timeoutInMs: 5_000 + } + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Load operation timed out."); + expect(attempt).eq(1); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); + + it("should not retry on non-retriable TypeError", async () => { + let attempt = 0; + const failForAllAttempts = () => { + attempt += 1; + throw new TypeError("Non-retriable Test Error"); + }; + mockAppConfigurationClientListConfigurationSettings( + [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)], + failForAllAttempts); + + try { + await load(createMockedConnectionString(), { + startupOptions: { + timeoutInMs: 10_000 + } + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Non-retriable Test Error"); + expect(attempt).eq(1); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); + + it("should not retry on non-retriable RangeError", async () => { + let attempt = 0; + const failForAllAttempts = () => { + attempt += 1; + throw new RangeError("Non-retriable Test Error"); + }; + mockAppConfigurationClientListConfigurationSettings( + [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)], + failForAllAttempts); + + try { + await load(createMockedConnectionString(), { + startupOptions: { + timeoutInMs: 10_000 + } + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Non-retriable Test Error"); + expect(attempt).eq(1); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); +}); From 7eb4fe0960d0ab29b526ff4a7e7ac7e992d10ac3 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 7 May 2025 17:04:27 +0800 Subject: [PATCH 3/9] Merge pull request #193 from Azure/zhiyuanliang/small-fix Use strong type for parseKeyVaultSecretIdentifier --- src/keyvault/AzureKeyVaultKeyValueAdapter.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts index 1b8c5977..11852c7b 100644 --- a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -5,7 +5,7 @@ import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@ import { IKeyValueAdapter } from "../IKeyValueAdapter.js"; import { KeyVaultOptions } from "./KeyVaultOptions.js"; import { ArgumentError, KeyVaultReferenceError } from "../common/error.js"; -import { SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; +import { KeyVaultSecretIdentifier, SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; import { isRestError } from "@azure/core-rest-pipeline"; import { AuthenticationError } from "@azure/identity"; @@ -29,32 +29,28 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { if (!this.#keyVaultOptions) { throw new ArgumentError("Failed to process the Key Vault reference because Key Vault options are not configured."); } - let secretName, vaultUrl, sourceId, version; + let secretIdentifier: KeyVaultSecretIdentifier; try { - const { name: parsedName, vaultUrl: parsedVaultUrl, sourceId: parsedSourceId, version: parsedVersion } = parseKeyVaultSecretIdentifier( + secretIdentifier = parseKeyVaultSecretIdentifier( parseSecretReference(setting).value.secretId ); - secretName = parsedName; - vaultUrl = parsedVaultUrl; - sourceId = parsedSourceId; - version = parsedVersion; } catch (error) { throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", setting), { cause: error }); } try { // precedence: secret clients > credential > secret resolver - const client = this.#getSecretClient(new URL(vaultUrl)); + const client = this.#getSecretClient(new URL(secretIdentifier.vaultUrl)); if (client) { - const secret = await client.getSecret(secretName, { version }); + const secret = await client.getSecret(secretIdentifier.name, { version: secretIdentifier.version }); return [setting.key, secret.value]; } if (this.#keyVaultOptions.secretResolver) { - return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(sourceId))]; + return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(secretIdentifier.sourceId))]; } } catch (error) { if (isRestError(error) || error instanceof AuthenticationError) { - throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", setting, sourceId), { cause: error }); + throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", setting, secretIdentifier.sourceId), { cause: error }); } throw error; } From 1e2f74da96d9349dd060af420b71e8199e5612f5 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 8 May 2025 16:48:05 +0800 Subject: [PATCH 4/9] Allow user to set SecretClientOptions (#194) * allow to set SecretClientOptions * fix lint --- src/keyvault/AzureKeyVaultKeyValueAdapter.ts | 2 +- src/keyvault/KeyVaultOptions.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts index 11852c7b..41a1ac89 100644 --- a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -80,7 +80,7 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { } if (this.#keyVaultOptions?.credential) { - client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential); + client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential, this.#keyVaultOptions.clientOptions); this.#secretClients.set(vaultUrl.host, client); return client; } diff --git a/src/keyvault/KeyVaultOptions.ts b/src/keyvault/KeyVaultOptions.ts index 6d476b54..f51d9699 100644 --- a/src/keyvault/KeyVaultOptions.ts +++ b/src/keyvault/KeyVaultOptions.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { TokenCredential } from "@azure/identity"; -import { SecretClient } from "@azure/keyvault-secrets"; +import { SecretClient, SecretClientOptions } from "@azure/keyvault-secrets"; /** * Options used to resolve Key Vault references. @@ -18,10 +18,18 @@ export interface KeyVaultOptions { */ credential?: TokenCredential; + /** + * Configures the client options used when connecting to key vaults that have no registered SecretClient. + * + * @remarks + * The client options will not affect the registered SecretClient instances. + */ + clientOptions?: SecretClientOptions; + /** * Specifies the callback used to resolve key vault references that have no applied SecretClient. * @param keyVaultReference The Key Vault reference to resolve. * @returns The secret value. */ secretResolver?: (keyVaultReference: URL) => string | Promise; -} +} From 28cbd6d975e31d43360503e5a8d445fdc00383bf Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Tue, 13 May 2025 13:31:40 +0800 Subject: [PATCH 5/9] Add .gitattributes and normalize line endings (#195) * add gitattributes * fix line endings --- .gitattributes | 3 + .github/workflows/ci.yml | 56 +- SUPPORT.md | 26 +- rollup.config.mjs | 98 +- src/AzureAppConfiguration.ts | 94 +- src/AzureAppConfigurationImpl.ts | 1726 +++++++++--------- src/AzureAppConfigurationOptions.ts | 138 +- src/IKeyValueAdapter.ts | 30 +- src/JsonKeyValueAdapter.ts | 76 +- src/WatchedSetting.ts | 34 +- src/common/disposable.ts | 36 +- src/featureManagement/FeatureFlagOptions.ts | 58 +- src/featureManagement/constants.ts | 52 +- src/index.ts | 16 +- src/keyvault/AzureKeyVaultKeyValueAdapter.ts | 188 +- src/keyvault/KeyVaultOptions.ts | 70 +- src/load.ts | 116 +- src/refresh/RefreshTimer.ts | 48 +- src/requestTracing/constants.ts | 160 +- src/requestTracing/utils.ts | 460 ++--- src/types.ts | 104 +- src/version.ts | 8 +- test/clientOptions.test.ts | 264 +-- test/exportedApi.ts | 6 +- test/featureFlag.test.ts | 680 +++---- test/json.test.ts | 182 +- test/keyvault.test.ts | 260 +-- test/load.test.ts | 842 ++++----- test/refresh.test.ts | 1104 +++++------ test/requestTracing.test.ts | 1282 ++++++------- test/utils/testHelper.ts | 568 +++--- tsconfig.base.json | 46 +- tsconfig.json | 18 +- tsconfig.test.json | 18 +- 34 files changed, 4435 insertions(+), 4432 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..02db30a0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# If there are abnormal line endings in any file, run "git add --renormalize ", +# review the changes, and commit them to fix the line endings. +* text=auto \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 997a794a..c6210e84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,28 +1,28 @@ -name: AppConfiguration-JavaScriptProvider CI - -on: - push: - branches: [ "main", "preview" ] - pull_request: - branches: [ "main", "preview" ] - -jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x, 22.x] - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run lint - - run: npm run build - - run: npm test +name: AppConfiguration-JavaScriptProvider CI + +on: + push: + branches: [ "main", "preview" ] + pull_request: + branches: [ "main", "preview" ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run lint + - run: npm run build + - run: npm test diff --git a/SUPPORT.md b/SUPPORT.md index 4cf80dd9..8297d33b 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,13 +1,13 @@ -# Support - -## How to file issues and get help - -This project uses GitHub Issues to track bugs and feature requests. Please search the existing -issues before filing new issues to avoid duplicates. For new issues, file your bug or -feature request as a new Issue. - -For help and questions about using this project, please ask a question in Stack Overflow with [azure-app-configuration](https://stackoverflow.com/questions/tagged/azure-app-configuration) tag. - -## Microsoft Support Policy - -Support for this project is limited to the resources listed above. +# Support + +## How to file issues and get help + +This project uses GitHub Issues to track bugs and feature requests. Please search the existing +issues before filing new issues to avoid duplicates. For new issues, file your bug or +feature request as a new Issue. + +For help and questions about using this project, please ask a question in Stack Overflow with [azure-app-configuration](https://stackoverflow.com/questions/tagged/azure-app-configuration) tag. + +## Microsoft Support Policy + +Support for this project is limited to the resources listed above. diff --git a/rollup.config.mjs b/rollup.config.mjs index 6f78ca44..16224a2f 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,49 +1,49 @@ -// rollup.config.js -import typescript from "@rollup/plugin-typescript"; -import dts from "rollup-plugin-dts"; - -export default [ - { - external: [ - "@azure/app-configuration", - "@azure/keyvault-secrets", - "@azure/core-rest-pipeline", - "@azure/identity", - "crypto", - "dns/promises", - "@microsoft/feature-management" - ], - input: "src/index.ts", - output: [ - { - file: "dist/index.js", - format: "cjs", - sourcemap: true - }, - ], - plugins: [ - typescript({ - compilerOptions: { - "lib": [ - "DOM", - "WebWorker", - "ESNext" - ], - "skipDefaultLibCheck": true, - "module": "ESNext", - "moduleResolution": "Node", - "target": "ES2022", - "strictNullChecks": true, - "strictFunctionTypes": true, - "sourceMap": true, - "inlineSources": true - } - }) - ], - }, - { - input: "src/index.ts", - output: [{ file: "types/index.d.ts", format: "es" }], - plugins: [dts()], - }, -]; +// rollup.config.js +import typescript from "@rollup/plugin-typescript"; +import dts from "rollup-plugin-dts"; + +export default [ + { + external: [ + "@azure/app-configuration", + "@azure/keyvault-secrets", + "@azure/core-rest-pipeline", + "@azure/identity", + "crypto", + "dns/promises", + "@microsoft/feature-management" + ], + input: "src/index.ts", + output: [ + { + file: "dist/index.js", + format: "cjs", + sourcemap: true + }, + ], + plugins: [ + typescript({ + compilerOptions: { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + } + }) + ], + }, + { + input: "src/index.ts", + output: [{ file: "types/index.d.ts", format: "es" }], + plugins: [dts()], + }, +]; diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index dbe2ce48..df7190b9 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -1,47 +1,47 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { Disposable } from "./common/disposable.js"; - -/** - * Azure App Configuration provider. - */ -export type AzureAppConfiguration = { - /** - * 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 or feature flags. - * - * @param listener - Callback function to be registered. - * @param thisArg - Optional. Value to use as `this` when executing callback. - */ - onRefresh(listener: () => any, thisArg?: any): Disposable; -} & IGettable & ReadonlyMap & IConfigurationObject; - -interface IConfigurationObject { - /** - * Construct configuration object based on Map-styled data structure and hierarchical keys. - * @param options - The options to control the conversion behavior. - */ - constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record; -} - -export interface ConfigurationObjectConstructionOptions { - /** - * The separator to use when converting hierarchical keys to object properties. - * Supported values: '.', ',', ';', '-', '_', '__', '/', ':'. - * If separator is undefined, '.' will be used by default. - */ - separator?: "." | "," | ";" | "-" | "_" | "__" | "/" | ":"; -} - -interface IGettable { - /** - * Get the value of a key-value from the Map-styled data structure. - * @param key - The key of the key-value to be retrieved. - */ - get(key: string): T | undefined; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Disposable } from "./common/disposable.js"; + +/** + * Azure App Configuration provider. + */ +export type AzureAppConfiguration = { + /** + * 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 or feature flags. + * + * @param listener - Callback function to be registered. + * @param thisArg - Optional. Value to use as `this` when executing callback. + */ + onRefresh(listener: () => any, thisArg?: any): Disposable; +} & IGettable & ReadonlyMap & IConfigurationObject; + +interface IConfigurationObject { + /** + * Construct configuration object based on Map-styled data structure and hierarchical keys. + * @param options - The options to control the conversion behavior. + */ + constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record; +} + +export interface ConfigurationObjectConstructionOptions { + /** + * The separator to use when converting hierarchical keys to object properties. + * Supported values: '.', ',', ';', '-', '_', '__', '/', ':'. + * If separator is undefined, '.' will be used by default. + */ + separator?: "." | "," | ";" | "-" | "_" | "__" | "/" | ":"; +} + +interface IGettable { + /** + * Get the value of a key-value from the Map-styled data structure. + * @param key - The key of the key-value to be retrieved. + */ + get(key: string): T | undefined; +} diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index fb523ab0..1491b806 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,863 +1,863 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag } from "@azure/app-configuration"; -import { isRestError } from "@azure/core-rest-pipeline"; -import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration.js"; -import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; -import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; -import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; -import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js"; -import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js"; -import { Disposable } from "./common/disposable.js"; -import { - FEATURE_FLAGS_KEY_NAME, - FEATURE_MANAGEMENT_KEY_NAME, - NAME_KEY_NAME, - TELEMETRY_KEY_NAME, - ENABLED_KEY_NAME, - METADATA_KEY_NAME, - ETAG_KEY_NAME, - FEATURE_FLAG_REFERENCE_KEY_NAME, - ALLOCATION_KEY_NAME, - SEED_KEY_NAME, - VARIANTS_KEY_NAME, - CONDITIONS_KEY_NAME, - CLIENT_FILTERS_KEY_NAME -} from "./featureManagement/constants.js"; -import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } from "./requestTracing/constants.js"; -import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js"; -import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js"; -import { RefreshTimer } from "./refresh/RefreshTimer.js"; -import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; -import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js"; -import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js"; -import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; -import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; -import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; -import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/error.js"; - -const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds - -type PagedSettingSelector = SettingSelector & { - /** - * Key: page eTag, Value: feature flag configurations - */ - pageEtags?: string[]; -}; - -export class AzureAppConfigurationImpl implements AzureAppConfiguration { - /** - * Hosting key-value pairs in the configuration store. - */ - #configMap: Map = new Map(); - - #adapters: IKeyValueAdapter[] = []; - /** - * Trim key prefixes sorted in descending order. - * Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. - */ - #sortedTrimKeyPrefixes: string[] | undefined; - readonly #requestTracingEnabled: boolean; - #clientManager: ConfigurationClientManager; - #options: AzureAppConfigurationOptions | undefined; - #isInitialLoadCompleted: boolean = false; - #isFailoverRequest: boolean = false; - #featureFlagTracing: FeatureFlagTracingOptions | undefined; - #fmVersion: string | undefined; - #aiConfigurationTracing: AIConfigurationTracingOptions | undefined; - - // Refresh - #refreshInProgress: boolean = false; - - #onRefreshListeners: Array<() => any> = []; - /** - * Aka watched settings. - */ - #sentinels: ConfigurationSettingId[] = []; - #watchAll: boolean = false; - #kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; - #kvRefreshTimer: RefreshTimer; - - // Feature flags - #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; - #ffRefreshTimer: RefreshTimer; - - /** - * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors - */ - #kvSelectors: PagedSettingSelector[] = []; - /** - * Selectors of feature flags obtained from @see AzureAppConfigurationOptions.featureFlagOptions.selectors - */ - #ffSelectors: PagedSettingSelector[] = []; - - // Load balancing - #lastSuccessfulEndpoint: string = ""; - - constructor( - clientManager: ConfigurationClientManager, - options: AzureAppConfigurationOptions | undefined, - ) { - this.#options = options; - this.#clientManager = clientManager; - - // enable request tracing if not opt-out - this.#requestTracingEnabled = requestTracingEnabled(); - if (this.#requestTracingEnabled) { - this.#aiConfigurationTracing = new AIConfigurationTracingOptions(); - this.#featureFlagTracing = new FeatureFlagTracingOptions(); - } - - if (options?.trimKeyPrefixes) { - this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); - } - - // if no selector is specified, always load key values using the default selector: key="*" and label="\0" - this.#kvSelectors = getValidKeyValueSelectors(options?.selectors); - - if (options?.refreshOptions?.enabled) { - const { refreshIntervalInMs, watchedSettings } = options.refreshOptions; - if (watchedSettings === undefined || watchedSettings.length === 0) { - this.#watchAll = true; // if no watched settings is specified, then watch all - } else { - for (const setting of watchedSettings) { - if (setting.key.includes("*") || setting.key.includes(",")) { - throw new ArgumentError("The characters '*' and ',' are not supported in key of watched settings."); - } - if (setting.label?.includes("*") || setting.label?.includes(",")) { - throw new ArgumentError("The characters '*' and ',' are not supported in label of watched settings."); - } - this.#sentinels.push(setting); - } - } - - // custom refresh interval - if (refreshIntervalInMs !== undefined) { - if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { - throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); - } else { - this.#kvRefreshInterval = refreshIntervalInMs; - } - } - this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval); - } - - // feature flag options - if (options?.featureFlagOptions?.enabled) { - // validate feature flag selectors, only load feature flags when enabled - this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); - - if (options.featureFlagOptions.refresh?.enabled) { - const { refreshIntervalInMs } = options.featureFlagOptions.refresh; - // custom refresh interval - if (refreshIntervalInMs !== undefined) { - if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { - throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); - } else { - this.#ffRefreshInterval = refreshIntervalInMs; - } - } - - this.#ffRefreshTimer = new RefreshTimer(this.#ffRefreshInterval); - } - } - - this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); - this.#adapters.push(new JsonKeyValueAdapter()); - } - - get #refreshEnabled(): boolean { - return !!this.#options?.refreshOptions?.enabled; - } - - get #featureFlagEnabled(): boolean { - return !!this.#options?.featureFlagOptions?.enabled; - } - - get #featureFlagRefreshEnabled(): boolean { - return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled; - } - - get #requestTraceOptions(): RequestTracingOptions { - return { - enabled: this.#requestTracingEnabled, - appConfigOptions: this.#options, - initialLoadCompleted: this.#isInitialLoadCompleted, - replicaCount: this.#clientManager.getReplicaCount(), - isFailoverRequest: this.#isFailoverRequest, - featureFlagTracing: this.#featureFlagTracing, - fmVersion: this.#fmVersion, - aiConfigurationTracing: this.#aiConfigurationTracing - }; - } - - // #region 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(): MapIterator<[string, any]> { - return this.#configMap.entries(); - } - - keys(): MapIterator { - return this.#configMap.keys(); - } - - values(): MapIterator { - return this.#configMap.values(); - } - - [Symbol.iterator](): MapIterator<[string, any]> { - return this.#configMap[Symbol.iterator](); - } - // #endregion - - /** - * Loads the configuration store for the first time. - */ - async load() { - const startTimestamp = Date.now(); - const startupTimeout: number = this.#options?.startupOptions?.timeoutInMs ?? DEFAULT_STARTUP_TIMEOUT_IN_MS; - const abortController = new AbortController(); - const abortSignal = abortController.signal; - let timeoutId; - try { - // Promise.race will be settled when the first promise in the list is settled. - // It will not cancel the remaining promises in the list. - // To avoid memory leaks, we must ensure other promises will be eventually terminated. - await Promise.race([ - this.#initializeWithRetryPolicy(abortSignal), - // this promise will be rejected after timeout - new Promise((_, reject) => { - timeoutId = setTimeout(() => { - abortController.abort(); // abort the initialization promise - reject(new Error("Load operation timed out.")); - }, - startupTimeout); - }) - ]); - } catch (error) { - if (!isInputError(error)) { - const timeElapsed = Date.now() - startTimestamp; - if (timeElapsed < MIN_DELAY_FOR_UNHANDLED_FAILURE) { - // 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. - await new Promise(resolve => setTimeout(resolve, MIN_DELAY_FOR_UNHANDLED_FAILURE - timeElapsed)); - } - } - throw new Error("Failed to load.", { cause: error }); - } finally { - clearTimeout(timeoutId); // cancel the timeout promise - } - } - - /** - * Constructs hierarchical data object from map. - */ - constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record { - const separator = options?.separator ?? "."; - const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"]; - if (!validSeparators.includes(separator)) { - throw new ArgumentError(`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 InvalidOperationError(`Failed to construct configuration object: 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 InvalidOperationError(`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 InvalidOperationError(`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; - } - - /** - * Refreshes the configuration. - */ - async refresh(): Promise { - if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); - } - - if (this.#refreshInProgress) { - return; - } - this.#refreshInProgress = true; - try { - await this.#refreshTasks(); - } finally { - this.#refreshInProgress = false; - } - } - - /** - * Registers a callback function to be called when the configuration is refreshed. - */ - onRefresh(listener: () => any, thisArg?: any): Disposable { - if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); - } - - 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); - } - - /** - * Initializes the configuration provider. - */ - async #initializeWithRetryPolicy(abortSignal: AbortSignal): Promise { - if (!this.#isInitialLoadCompleted) { - await this.#inspectFmPackage(); - const startTimestamp = Date.now(); - let postAttempts = 0; - do { // at least try to load once - try { - await this.#loadSelectedAndWatchedKeyValues(); - if (this.#featureFlagEnabled) { - await this.#loadFeatureFlags(); - } - this.#isInitialLoadCompleted = true; - break; - } catch (error) { - if (isInputError(error)) { - throw error; - } - if (abortSignal.aborted) { - return; - } - const timeElapsed = Date.now() - startTimestamp; - let backoffDuration = getFixedBackoffDuration(timeElapsed); - if (backoffDuration === undefined) { - postAttempts += 1; - backoffDuration = getExponentialBackoffDuration(postAttempts); - } - console.warn(`Failed to load. Error message: ${error.message}. Retrying in ${backoffDuration} ms.`); - await new Promise(resolve => setTimeout(resolve, backoffDuration)); - } - } while (!abortSignal.aborted); - } - } - - /** - * Inspects the feature management package version. - */ - async #inspectFmPackage() { - if (this.#requestTracingEnabled && !this.#fmVersion) { - try { - // get feature management package version - const fmPackage = await import(FM_PACKAGE_NAME); - this.#fmVersion = fmPackage?.VERSION; - } catch (error) { - // ignore the error - } - } - } - - async #refreshTasks(): Promise { - const refreshTasks: Promise[] = []; - if (this.#refreshEnabled) { - refreshTasks.push(this.#refreshKeyValues()); - } - if (this.#featureFlagRefreshEnabled) { - refreshTasks.push(this.#refreshFeatureFlags()); - } - - // wait until all tasks are either resolved or rejected - const results = await Promise.allSettled(refreshTasks); - - // check if any refresh task failed - for (const result of results) { - if (result.status === "rejected") { - console.warn("Refresh failed:", result.reason); - } - } - - // check if any refresh task succeeded - const anyRefreshed = results.some(result => result.status === "fulfilled" && result.value === true); - if (anyRefreshed) { - // successfully refreshed, run callbacks in async - for (const listener of this.#onRefreshListeners) { - listener(); - } - } - } - - /** - * Loads configuration settings from App Configuration, either key-value settings or feature flag settings. - * Additionally, updates the `pageEtags` property of the corresponding @see PagedSettingSelector after loading. - * - * @param loadFeatureFlag - Determines which type of configurationsettings to load: - * If true, loads feature flag using the feature flag selectors; - * If false, loads key-value using the key-value selectors. Defaults to false. - */ - async #loadConfigurationSettings(loadFeatureFlag: boolean = false): Promise { - const selectors = loadFeatureFlag ? this.#ffSelectors : this.#kvSelectors; - const funcToExecute = async (client) => { - const loadedSettings: ConfigurationSetting[] = []; - // deep copy selectors to avoid modification if current client fails - const selectorsToUpdate = JSON.parse( - JSON.stringify(selectors) - ); - - for (const selector of selectorsToUpdate) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter - }; - - const pageEtags: string[] = []; - const pageIterator = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - client, - listOptions - ).byPage(); - for await (const page of pageIterator) { - pageEtags.push(page.etag ?? ""); - for (const setting of page.items) { - if (loadFeatureFlag === isFeatureFlag(setting)) { - loadedSettings.push(setting); - } - } - } - selector.pageEtags = pageEtags; - } - - if (loadFeatureFlag) { - this.#ffSelectors = selectorsToUpdate; - } else { - this.#kvSelectors = selectorsToUpdate; - } - return loadedSettings; - }; - - return await this.#executeWithFailoverPolicy(funcToExecute) as ConfigurationSetting[]; - } - - /** - * Loads selected key-values and watched settings (sentinels) for refresh from App Configuration to the local configuration. - */ - async #loadSelectedAndWatchedKeyValues() { - const keyValues: [key: string, value: unknown][] = []; - const loadedSettings = await this.#loadConfigurationSettings(); - if (this.#refreshEnabled && !this.#watchAll) { - await this.#updateWatchedKeyValuesEtag(loadedSettings); - } - - if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) { - // Reset old AI configuration tracing in order to track the information present in the current response from server. - this.#aiConfigurationTracing.reset(); - } - - // adapt configuration settings to key-values - for (const setting of loadedSettings) { - const [key, value] = await this.#processKeyValue(setting); - keyValues.push([key, value]); - } - - this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion - for (const [k, v] of keyValues) { - this.#configMap.set(k, v); // reset the configuration - } - } - - /** - * Updates 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 { - 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.#getConfigurationSetting({ key, label }); - if (response) { - sentinel.etag = response.etag; - } else { - sentinel.etag = undefined; - } - } - } - } - - /** - * Clears all existing key-values in the local configuration except feature flags. - */ - async #clearLoadedKeyValues() { - for (const key of this.#configMap.keys()) { - if (key !== FEATURE_MANAGEMENT_KEY_NAME) { - this.#configMap.delete(key); - } - } - } - - /** - * Loads feature flags from App Configuration to the local configuration. - */ - async #loadFeatureFlags() { - const loadFeatureFlag = true; - const featureFlagSettings = await this.#loadConfigurationSettings(loadFeatureFlag); - - if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { - // Reset old feature flag tracing in order to track the information present in the current response from server. - this.#featureFlagTracing.reset(); - } - - // parse feature flags - const featureFlags = await Promise.all( - featureFlagSettings.map(setting => this.#parseFeatureFlag(setting)) - ); - - // feature_management is a reserved key, and feature_flags is an array of feature flags - this.#configMap.set(FEATURE_MANAGEMENT_KEY_NAME, { [FEATURE_FLAGS_KEY_NAME]: featureFlags }); - } - - /** - * Refreshes key-values. - * @returns true if key-values are refreshed, false otherwise. - */ - async #refreshKeyValues(): Promise { - // if still within refresh interval/backoff, return - if (!this.#kvRefreshTimer.canRefresh()) { - return Promise.resolve(false); - } - - // try refresh if any of watched settings is changed. - let needRefresh = false; - if (this.#watchAll) { - needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); - } - for (const sentinel of this.#sentinels.values()) { - const response = await this.#getConfigurationSetting(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) { - await this.#loadSelectedAndWatchedKeyValues(); - } - - this.#kvRefreshTimer.reset(); - return Promise.resolve(needRefresh); - } - - /** - * Refreshes feature flags. - * @returns true if feature flags are refreshed, false otherwise. - */ - async #refreshFeatureFlags(): Promise { - // if still within refresh interval/backoff, return - if (!this.#ffRefreshTimer.canRefresh()) { - return Promise.resolve(false); - } - - const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); - if (needRefresh) { - await this.#loadFeatureFlags(); - } - - this.#ffRefreshTimer.reset(); - return Promise.resolve(needRefresh); - } - - /** - * Checks whether the key-value collection has changed. - * @param selectors - The @see PagedSettingSelector of the kev-value collection. - * @returns true if key-value collection has changed, false otherwise. - */ - async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise { - const funcToExecute = async (client) => { - for (const selector of selectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter, - pageEtags: selector.pageEtags - }; - - const pageIterator = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - client, - listOptions - ).byPage(); - - for await (const page of pageIterator) { - if (page._response.status === 200) { // created or changed - return true; - } - } - } - return false; - }; - - const isChanged = await this.#executeWithFailoverPolicy(funcToExecute); - return isChanged; - } - - /** - * Gets a configuration setting by key and label.If the setting is not found, return undefine instead of throwing an error. - */ - async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { - const funcToExecute = async (client) => { - return getConfigurationSettingWithTrace( - this.#requestTraceOptions, - client, - configurationSettingId, - customOptions - ); - }; - - let response: GetConfigurationSettingResponse | undefined; - try { - response = await this.#executeWithFailoverPolicy(funcToExecute); - } catch (error) { - if (isRestError(error) && error.statusCode === 404) { - response = undefined; - } else { - throw error; - } - } - return response; - } - - // Only operations related to Azure App Configuration should be executed with failover policy. - async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { - let clientWrappers = await this.#clientManager.getClients(); - if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) { - let nextClientIndex = 0; - // Iterate through clients to find the index of the client with the last successful endpoint - for (const clientWrapper of clientWrappers) { - nextClientIndex++; - if (clientWrapper.endpoint === this.#lastSuccessfulEndpoint) { - break; - } - } - // If we found the last successful client, rotate the list so that the next client is at the beginning - if (nextClientIndex < clientWrappers.length) { - clientWrappers = [...clientWrappers.slice(nextClientIndex), ...clientWrappers.slice(0, nextClientIndex)]; - } - } - - let successful: boolean; - for (const clientWrapper of clientWrappers) { - successful = false; - try { - const result = await funcToExecute(clientWrapper.client); - this.#isFailoverRequest = false; - this.#lastSuccessfulEndpoint = clientWrapper.endpoint; - successful = true; - clientWrapper.updateBackoffStatus(successful); - return result; - } catch (error) { - if (isFailoverableError(error)) { - clientWrapper.updateBackoffStatus(successful); - this.#isFailoverRequest = true; - continue; - } - - throw error; - } - } - - this.#clientManager.refreshClients(); - throw new Error("All fallback clients failed to get configuration settings."); - } - - async #processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { - this.#setAIConfigurationTracing(setting); - - const [key, value] = await this.#processAdapters(setting); - const trimmedKey = this.#keyWithPrefixesTrimmed(key); - return [trimmedKey, value]; - } - - #setAIConfigurationTracing(setting: ConfigurationSetting): void { - if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) { - const contentType = parseContentType(setting.contentType); - // content type: "application/json; profile=\"https://azconfig.io/mime-profiles/ai\""" - if (isJsonContentType(contentType) && - !isFeatureFlagContentType(contentType) && - !isSecretReferenceContentType(contentType)) { - const profile = contentType?.parameters["profile"]; - if (profile === undefined) { - return; - } - if (profile.includes(AI_MIME_PROFILE)) { - this.#aiConfigurationTracing.usesAIConfiguration = true; - } - if (profile.includes(AI_CHAT_COMPLETION_MIME_PROFILE)) { - this.#aiConfigurationTracing.usesAIChatCompletionConfiguration = true; - } - } - } - } - - async #processAdapters(setting: ConfigurationSetting): Promise<[string, unknown]> { - for (const adapter of this.#adapters) { - if (adapter.canProcess(setting)) { - return adapter.processKeyValue(setting); - } - } - return [setting.key, setting.value]; - } - - #keyWithPrefixesTrimmed(key: string): string { - if (this.#sortedTrimKeyPrefixes) { - for (const prefix of this.#sortedTrimKeyPrefixes) { - if (key.startsWith(prefix)) { - return key.slice(prefix.length); - } - } - } - return key; - } - - async #parseFeatureFlag(setting: ConfigurationSetting): Promise { - const rawFlag = setting.value; - if (rawFlag === undefined) { - throw new ArgumentError("The value of configuration setting cannot be undefined."); - } - const featureFlag = JSON.parse(rawFlag); - - if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME] === true) { - const metadata = featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME]; - featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME] = { - [ETAG_KEY_NAME]: setting.etag, - [FEATURE_FLAG_REFERENCE_KEY_NAME]: this.#createFeatureFlagReference(setting), - ...(metadata || {}) - }; - } - - this.#setFeatureFlagTracing(featureFlag); - - return featureFlag; - } - - #createFeatureFlagReference(setting: ConfigurationSetting): string { - let featureFlagReference = `${this.#clientManager.endpoint.origin}/kv/${setting.key}`; - if (setting.label && setting.label.trim().length !== 0) { - featureFlagReference += `?label=${setting.label}`; - } - return featureFlagReference; - } - - #setFeatureFlagTracing(featureFlag: any): void { - if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { - if (featureFlag[CONDITIONS_KEY_NAME] && - featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME] && - Array.isArray(featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME])) { - for (const filter of featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME]) { - this.#featureFlagTracing.updateFeatureFilterTracing(filter[NAME_KEY_NAME]); - } - } - if (featureFlag[VARIANTS_KEY_NAME] && Array.isArray(featureFlag[VARIANTS_KEY_NAME])) { - this.#featureFlagTracing.notifyMaxVariants(featureFlag[VARIANTS_KEY_NAME].length); - } - if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME]) { - this.#featureFlagTracing.usesTelemetry = true; - } - if (featureFlag[ALLOCATION_KEY_NAME] && featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME]) { - this.#featureFlagTracing.usesSeed = true; - } - } - } -} - -function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { - // below code deduplicates selectors by keyFilter and labelFilter, the latter selector wins - const uniqueSelectors: SettingSelector[] = []; - for (const selector of selectors) { - const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter); - if (existingSelectorIndex >= 0) { - uniqueSelectors.splice(existingSelectorIndex, 1); - } - uniqueSelectors.push(selector); - } - - return uniqueSelectors.map(selectorCandidate => { - const selector = { ...selectorCandidate }; - if (!selector.keyFilter) { - throw new ArgumentError("Key filter cannot be null or empty."); - } - if (!selector.labelFilter) { - selector.labelFilter = LabelFilter.Null; - } - if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { - throw new ArgumentError("The characters '*' and ',' are not supported in label filters."); - } - return selector; - }); -} - -function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] { - if (selectors === undefined || selectors.length === 0) { - // Default selector: key: *, label: \0 - return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; - } - return getValidSelectors(selectors); -} - -function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] { - if (selectors === undefined || selectors.length === 0) { - // Default selector: key: *, label: \0 - return [{ keyFilter: `${featureFlagPrefix}${KeyFilter.Any}`, labelFilter: LabelFilter.Null }]; - } - selectors.forEach(selector => { - selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`; - }); - return getValidSelectors(selectors); -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag } from "@azure/app-configuration"; +import { isRestError } from "@azure/core-rest-pipeline"; +import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration.js"; +import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; +import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; +import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; +import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js"; +import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js"; +import { Disposable } from "./common/disposable.js"; +import { + FEATURE_FLAGS_KEY_NAME, + FEATURE_MANAGEMENT_KEY_NAME, + NAME_KEY_NAME, + TELEMETRY_KEY_NAME, + ENABLED_KEY_NAME, + METADATA_KEY_NAME, + ETAG_KEY_NAME, + FEATURE_FLAG_REFERENCE_KEY_NAME, + ALLOCATION_KEY_NAME, + SEED_KEY_NAME, + VARIANTS_KEY_NAME, + CONDITIONS_KEY_NAME, + CLIENT_FILTERS_KEY_NAME +} from "./featureManagement/constants.js"; +import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } from "./requestTracing/constants.js"; +import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js"; +import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js"; +import { RefreshTimer } from "./refresh/RefreshTimer.js"; +import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; +import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js"; +import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js"; +import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; +import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; +import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; +import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/error.js"; + +const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds + +type PagedSettingSelector = SettingSelector & { + /** + * Key: page eTag, Value: feature flag configurations + */ + pageEtags?: string[]; +}; + +export class AzureAppConfigurationImpl implements AzureAppConfiguration { + /** + * Hosting key-value pairs in the configuration store. + */ + #configMap: Map = new Map(); + + #adapters: IKeyValueAdapter[] = []; + /** + * Trim key prefixes sorted in descending order. + * Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. + */ + #sortedTrimKeyPrefixes: string[] | undefined; + readonly #requestTracingEnabled: boolean; + #clientManager: ConfigurationClientManager; + #options: AzureAppConfigurationOptions | undefined; + #isInitialLoadCompleted: boolean = false; + #isFailoverRequest: boolean = false; + #featureFlagTracing: FeatureFlagTracingOptions | undefined; + #fmVersion: string | undefined; + #aiConfigurationTracing: AIConfigurationTracingOptions | undefined; + + // Refresh + #refreshInProgress: boolean = false; + + #onRefreshListeners: Array<() => any> = []; + /** + * Aka watched settings. + */ + #sentinels: ConfigurationSettingId[] = []; + #watchAll: boolean = false; + #kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; + #kvRefreshTimer: RefreshTimer; + + // Feature flags + #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; + #ffRefreshTimer: RefreshTimer; + + /** + * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors + */ + #kvSelectors: PagedSettingSelector[] = []; + /** + * Selectors of feature flags obtained from @see AzureAppConfigurationOptions.featureFlagOptions.selectors + */ + #ffSelectors: PagedSettingSelector[] = []; + + // Load balancing + #lastSuccessfulEndpoint: string = ""; + + constructor( + clientManager: ConfigurationClientManager, + options: AzureAppConfigurationOptions | undefined, + ) { + this.#options = options; + this.#clientManager = clientManager; + + // enable request tracing if not opt-out + this.#requestTracingEnabled = requestTracingEnabled(); + if (this.#requestTracingEnabled) { + this.#aiConfigurationTracing = new AIConfigurationTracingOptions(); + this.#featureFlagTracing = new FeatureFlagTracingOptions(); + } + + if (options?.trimKeyPrefixes) { + this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); + } + + // if no selector is specified, always load key values using the default selector: key="*" and label="\0" + this.#kvSelectors = getValidKeyValueSelectors(options?.selectors); + + if (options?.refreshOptions?.enabled) { + const { refreshIntervalInMs, watchedSettings } = options.refreshOptions; + if (watchedSettings === undefined || watchedSettings.length === 0) { + this.#watchAll = true; // if no watched settings is specified, then watch all + } else { + for (const setting of watchedSettings) { + if (setting.key.includes("*") || setting.key.includes(",")) { + throw new ArgumentError("The characters '*' and ',' are not supported in key of watched settings."); + } + if (setting.label?.includes("*") || setting.label?.includes(",")) { + throw new ArgumentError("The characters '*' and ',' are not supported in label of watched settings."); + } + this.#sentinels.push(setting); + } + } + + // custom refresh interval + if (refreshIntervalInMs !== undefined) { + if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { + throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); + } else { + this.#kvRefreshInterval = refreshIntervalInMs; + } + } + this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval); + } + + // feature flag options + if (options?.featureFlagOptions?.enabled) { + // validate feature flag selectors, only load feature flags when enabled + this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); + + if (options.featureFlagOptions.refresh?.enabled) { + const { refreshIntervalInMs } = options.featureFlagOptions.refresh; + // custom refresh interval + if (refreshIntervalInMs !== undefined) { + if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { + throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); + } else { + this.#ffRefreshInterval = refreshIntervalInMs; + } + } + + this.#ffRefreshTimer = new RefreshTimer(this.#ffRefreshInterval); + } + } + + this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); + this.#adapters.push(new JsonKeyValueAdapter()); + } + + get #refreshEnabled(): boolean { + return !!this.#options?.refreshOptions?.enabled; + } + + get #featureFlagEnabled(): boolean { + return !!this.#options?.featureFlagOptions?.enabled; + } + + get #featureFlagRefreshEnabled(): boolean { + return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled; + } + + get #requestTraceOptions(): RequestTracingOptions { + return { + enabled: this.#requestTracingEnabled, + appConfigOptions: this.#options, + initialLoadCompleted: this.#isInitialLoadCompleted, + replicaCount: this.#clientManager.getReplicaCount(), + isFailoverRequest: this.#isFailoverRequest, + featureFlagTracing: this.#featureFlagTracing, + fmVersion: this.#fmVersion, + aiConfigurationTracing: this.#aiConfigurationTracing + }; + } + + // #region 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(): MapIterator<[string, any]> { + return this.#configMap.entries(); + } + + keys(): MapIterator { + return this.#configMap.keys(); + } + + values(): MapIterator { + return this.#configMap.values(); + } + + [Symbol.iterator](): MapIterator<[string, any]> { + return this.#configMap[Symbol.iterator](); + } + // #endregion + + /** + * Loads the configuration store for the first time. + */ + async load() { + const startTimestamp = Date.now(); + const startupTimeout: number = this.#options?.startupOptions?.timeoutInMs ?? DEFAULT_STARTUP_TIMEOUT_IN_MS; + const abortController = new AbortController(); + const abortSignal = abortController.signal; + let timeoutId; + try { + // Promise.race will be settled when the first promise in the list is settled. + // It will not cancel the remaining promises in the list. + // To avoid memory leaks, we must ensure other promises will be eventually terminated. + await Promise.race([ + this.#initializeWithRetryPolicy(abortSignal), + // this promise will be rejected after timeout + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + abortController.abort(); // abort the initialization promise + reject(new Error("Load operation timed out.")); + }, + startupTimeout); + }) + ]); + } catch (error) { + if (!isInputError(error)) { + const timeElapsed = Date.now() - startTimestamp; + if (timeElapsed < MIN_DELAY_FOR_UNHANDLED_FAILURE) { + // 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. + await new Promise(resolve => setTimeout(resolve, MIN_DELAY_FOR_UNHANDLED_FAILURE - timeElapsed)); + } + } + throw new Error("Failed to load.", { cause: error }); + } finally { + clearTimeout(timeoutId); // cancel the timeout promise + } + } + + /** + * Constructs hierarchical data object from map. + */ + constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record { + const separator = options?.separator ?? "."; + const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"]; + if (!validSeparators.includes(separator)) { + throw new ArgumentError(`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 InvalidOperationError(`Failed to construct configuration object: 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 InvalidOperationError(`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 InvalidOperationError(`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; + } + + /** + * Refreshes the configuration. + */ + async refresh(): Promise { + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { + throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); + } + + if (this.#refreshInProgress) { + return; + } + this.#refreshInProgress = true; + try { + await this.#refreshTasks(); + } finally { + this.#refreshInProgress = false; + } + } + + /** + * Registers a callback function to be called when the configuration is refreshed. + */ + onRefresh(listener: () => any, thisArg?: any): Disposable { + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { + throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); + } + + 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); + } + + /** + * Initializes the configuration provider. + */ + async #initializeWithRetryPolicy(abortSignal: AbortSignal): Promise { + if (!this.#isInitialLoadCompleted) { + await this.#inspectFmPackage(); + const startTimestamp = Date.now(); + let postAttempts = 0; + do { // at least try to load once + try { + await this.#loadSelectedAndWatchedKeyValues(); + if (this.#featureFlagEnabled) { + await this.#loadFeatureFlags(); + } + this.#isInitialLoadCompleted = true; + break; + } catch (error) { + if (isInputError(error)) { + throw error; + } + if (abortSignal.aborted) { + return; + } + const timeElapsed = Date.now() - startTimestamp; + let backoffDuration = getFixedBackoffDuration(timeElapsed); + if (backoffDuration === undefined) { + postAttempts += 1; + backoffDuration = getExponentialBackoffDuration(postAttempts); + } + console.warn(`Failed to load. Error message: ${error.message}. Retrying in ${backoffDuration} ms.`); + await new Promise(resolve => setTimeout(resolve, backoffDuration)); + } + } while (!abortSignal.aborted); + } + } + + /** + * Inspects the feature management package version. + */ + async #inspectFmPackage() { + if (this.#requestTracingEnabled && !this.#fmVersion) { + try { + // get feature management package version + const fmPackage = await import(FM_PACKAGE_NAME); + this.#fmVersion = fmPackage?.VERSION; + } catch (error) { + // ignore the error + } + } + } + + async #refreshTasks(): Promise { + const refreshTasks: Promise[] = []; + if (this.#refreshEnabled) { + refreshTasks.push(this.#refreshKeyValues()); + } + if (this.#featureFlagRefreshEnabled) { + refreshTasks.push(this.#refreshFeatureFlags()); + } + + // wait until all tasks are either resolved or rejected + const results = await Promise.allSettled(refreshTasks); + + // check if any refresh task failed + for (const result of results) { + if (result.status === "rejected") { + console.warn("Refresh failed:", result.reason); + } + } + + // check if any refresh task succeeded + const anyRefreshed = results.some(result => result.status === "fulfilled" && result.value === true); + if (anyRefreshed) { + // successfully refreshed, run callbacks in async + for (const listener of this.#onRefreshListeners) { + listener(); + } + } + } + + /** + * Loads configuration settings from App Configuration, either key-value settings or feature flag settings. + * Additionally, updates the `pageEtags` property of the corresponding @see PagedSettingSelector after loading. + * + * @param loadFeatureFlag - Determines which type of configurationsettings to load: + * If true, loads feature flag using the feature flag selectors; + * If false, loads key-value using the key-value selectors. Defaults to false. + */ + async #loadConfigurationSettings(loadFeatureFlag: boolean = false): Promise { + const selectors = loadFeatureFlag ? this.#ffSelectors : this.#kvSelectors; + const funcToExecute = async (client) => { + const loadedSettings: ConfigurationSetting[] = []; + // deep copy selectors to avoid modification if current client fails + const selectorsToUpdate = JSON.parse( + JSON.stringify(selectors) + ); + + for (const selector of selectorsToUpdate) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: selector.keyFilter, + labelFilter: selector.labelFilter + }; + + const pageEtags: string[] = []; + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + client, + listOptions + ).byPage(); + for await (const page of pageIterator) { + pageEtags.push(page.etag ?? ""); + for (const setting of page.items) { + if (loadFeatureFlag === isFeatureFlag(setting)) { + loadedSettings.push(setting); + } + } + } + selector.pageEtags = pageEtags; + } + + if (loadFeatureFlag) { + this.#ffSelectors = selectorsToUpdate; + } else { + this.#kvSelectors = selectorsToUpdate; + } + return loadedSettings; + }; + + return await this.#executeWithFailoverPolicy(funcToExecute) as ConfigurationSetting[]; + } + + /** + * Loads selected key-values and watched settings (sentinels) for refresh from App Configuration to the local configuration. + */ + async #loadSelectedAndWatchedKeyValues() { + const keyValues: [key: string, value: unknown][] = []; + const loadedSettings = await this.#loadConfigurationSettings(); + if (this.#refreshEnabled && !this.#watchAll) { + await this.#updateWatchedKeyValuesEtag(loadedSettings); + } + + if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) { + // Reset old AI configuration tracing in order to track the information present in the current response from server. + this.#aiConfigurationTracing.reset(); + } + + // adapt configuration settings to key-values + for (const setting of loadedSettings) { + const [key, value] = await this.#processKeyValue(setting); + keyValues.push([key, value]); + } + + this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion + for (const [k, v] of keyValues) { + this.#configMap.set(k, v); // reset the configuration + } + } + + /** + * Updates 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 { + 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.#getConfigurationSetting({ key, label }); + if (response) { + sentinel.etag = response.etag; + } else { + sentinel.etag = undefined; + } + } + } + } + + /** + * Clears all existing key-values in the local configuration except feature flags. + */ + async #clearLoadedKeyValues() { + for (const key of this.#configMap.keys()) { + if (key !== FEATURE_MANAGEMENT_KEY_NAME) { + this.#configMap.delete(key); + } + } + } + + /** + * Loads feature flags from App Configuration to the local configuration. + */ + async #loadFeatureFlags() { + const loadFeatureFlag = true; + const featureFlagSettings = await this.#loadConfigurationSettings(loadFeatureFlag); + + if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { + // Reset old feature flag tracing in order to track the information present in the current response from server. + this.#featureFlagTracing.reset(); + } + + // parse feature flags + const featureFlags = await Promise.all( + featureFlagSettings.map(setting => this.#parseFeatureFlag(setting)) + ); + + // feature_management is a reserved key, and feature_flags is an array of feature flags + this.#configMap.set(FEATURE_MANAGEMENT_KEY_NAME, { [FEATURE_FLAGS_KEY_NAME]: featureFlags }); + } + + /** + * Refreshes key-values. + * @returns true if key-values are refreshed, false otherwise. + */ + async #refreshKeyValues(): Promise { + // if still within refresh interval/backoff, return + if (!this.#kvRefreshTimer.canRefresh()) { + return Promise.resolve(false); + } + + // try refresh if any of watched settings is changed. + let needRefresh = false; + if (this.#watchAll) { + needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); + } + for (const sentinel of this.#sentinels.values()) { + const response = await this.#getConfigurationSetting(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) { + await this.#loadSelectedAndWatchedKeyValues(); + } + + this.#kvRefreshTimer.reset(); + return Promise.resolve(needRefresh); + } + + /** + * Refreshes feature flags. + * @returns true if feature flags are refreshed, false otherwise. + */ + async #refreshFeatureFlags(): Promise { + // if still within refresh interval/backoff, return + if (!this.#ffRefreshTimer.canRefresh()) { + return Promise.resolve(false); + } + + const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); + if (needRefresh) { + await this.#loadFeatureFlags(); + } + + this.#ffRefreshTimer.reset(); + return Promise.resolve(needRefresh); + } + + /** + * Checks whether the key-value collection has changed. + * @param selectors - The @see PagedSettingSelector of the kev-value collection. + * @returns true if key-value collection has changed, false otherwise. + */ + async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise { + const funcToExecute = async (client) => { + for (const selector of selectors) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: selector.keyFilter, + labelFilter: selector.labelFilter, + pageEtags: selector.pageEtags + }; + + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + client, + listOptions + ).byPage(); + + for await (const page of pageIterator) { + if (page._response.status === 200) { // created or changed + return true; + } + } + } + return false; + }; + + const isChanged = await this.#executeWithFailoverPolicy(funcToExecute); + return isChanged; + } + + /** + * Gets a configuration setting by key and label.If the setting is not found, return undefine instead of throwing an error. + */ + async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { + const funcToExecute = async (client) => { + return getConfigurationSettingWithTrace( + this.#requestTraceOptions, + client, + configurationSettingId, + customOptions + ); + }; + + let response: GetConfigurationSettingResponse | undefined; + try { + response = await this.#executeWithFailoverPolicy(funcToExecute); + } catch (error) { + if (isRestError(error) && error.statusCode === 404) { + response = undefined; + } else { + throw error; + } + } + return response; + } + + // Only operations related to Azure App Configuration should be executed with failover policy. + async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { + let clientWrappers = await this.#clientManager.getClients(); + if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) { + let nextClientIndex = 0; + // Iterate through clients to find the index of the client with the last successful endpoint + for (const clientWrapper of clientWrappers) { + nextClientIndex++; + if (clientWrapper.endpoint === this.#lastSuccessfulEndpoint) { + break; + } + } + // If we found the last successful client, rotate the list so that the next client is at the beginning + if (nextClientIndex < clientWrappers.length) { + clientWrappers = [...clientWrappers.slice(nextClientIndex), ...clientWrappers.slice(0, nextClientIndex)]; + } + } + + let successful: boolean; + for (const clientWrapper of clientWrappers) { + successful = false; + try { + const result = await funcToExecute(clientWrapper.client); + this.#isFailoverRequest = false; + this.#lastSuccessfulEndpoint = clientWrapper.endpoint; + successful = true; + clientWrapper.updateBackoffStatus(successful); + return result; + } catch (error) { + if (isFailoverableError(error)) { + clientWrapper.updateBackoffStatus(successful); + this.#isFailoverRequest = true; + continue; + } + + throw error; + } + } + + this.#clientManager.refreshClients(); + throw new Error("All fallback clients failed to get configuration settings."); + } + + async #processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { + this.#setAIConfigurationTracing(setting); + + const [key, value] = await this.#processAdapters(setting); + const trimmedKey = this.#keyWithPrefixesTrimmed(key); + return [trimmedKey, value]; + } + + #setAIConfigurationTracing(setting: ConfigurationSetting): void { + if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) { + const contentType = parseContentType(setting.contentType); + // content type: "application/json; profile=\"https://azconfig.io/mime-profiles/ai\""" + if (isJsonContentType(contentType) && + !isFeatureFlagContentType(contentType) && + !isSecretReferenceContentType(contentType)) { + const profile = contentType?.parameters["profile"]; + if (profile === undefined) { + return; + } + if (profile.includes(AI_MIME_PROFILE)) { + this.#aiConfigurationTracing.usesAIConfiguration = true; + } + if (profile.includes(AI_CHAT_COMPLETION_MIME_PROFILE)) { + this.#aiConfigurationTracing.usesAIChatCompletionConfiguration = true; + } + } + } + } + + async #processAdapters(setting: ConfigurationSetting): Promise<[string, unknown]> { + for (const adapter of this.#adapters) { + if (adapter.canProcess(setting)) { + return adapter.processKeyValue(setting); + } + } + return [setting.key, setting.value]; + } + + #keyWithPrefixesTrimmed(key: string): string { + if (this.#sortedTrimKeyPrefixes) { + for (const prefix of this.#sortedTrimKeyPrefixes) { + if (key.startsWith(prefix)) { + return key.slice(prefix.length); + } + } + } + return key; + } + + async #parseFeatureFlag(setting: ConfigurationSetting): Promise { + const rawFlag = setting.value; + if (rawFlag === undefined) { + throw new ArgumentError("The value of configuration setting cannot be undefined."); + } + const featureFlag = JSON.parse(rawFlag); + + if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME] === true) { + const metadata = featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME]; + featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME] = { + [ETAG_KEY_NAME]: setting.etag, + [FEATURE_FLAG_REFERENCE_KEY_NAME]: this.#createFeatureFlagReference(setting), + ...(metadata || {}) + }; + } + + this.#setFeatureFlagTracing(featureFlag); + + return featureFlag; + } + + #createFeatureFlagReference(setting: ConfigurationSetting): string { + let featureFlagReference = `${this.#clientManager.endpoint.origin}/kv/${setting.key}`; + if (setting.label && setting.label.trim().length !== 0) { + featureFlagReference += `?label=${setting.label}`; + } + return featureFlagReference; + } + + #setFeatureFlagTracing(featureFlag: any): void { + if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { + if (featureFlag[CONDITIONS_KEY_NAME] && + featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME] && + Array.isArray(featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME])) { + for (const filter of featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME]) { + this.#featureFlagTracing.updateFeatureFilterTracing(filter[NAME_KEY_NAME]); + } + } + if (featureFlag[VARIANTS_KEY_NAME] && Array.isArray(featureFlag[VARIANTS_KEY_NAME])) { + this.#featureFlagTracing.notifyMaxVariants(featureFlag[VARIANTS_KEY_NAME].length); + } + if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME]) { + this.#featureFlagTracing.usesTelemetry = true; + } + if (featureFlag[ALLOCATION_KEY_NAME] && featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME]) { + this.#featureFlagTracing.usesSeed = true; + } + } + } +} + +function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { + // below code deduplicates selectors by keyFilter and labelFilter, the latter selector wins + const uniqueSelectors: SettingSelector[] = []; + for (const selector of selectors) { + const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter); + if (existingSelectorIndex >= 0) { + uniqueSelectors.splice(existingSelectorIndex, 1); + } + uniqueSelectors.push(selector); + } + + return uniqueSelectors.map(selectorCandidate => { + const selector = { ...selectorCandidate }; + if (!selector.keyFilter) { + throw new ArgumentError("Key filter cannot be null or empty."); + } + if (!selector.labelFilter) { + selector.labelFilter = LabelFilter.Null; + } + if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { + throw new ArgumentError("The characters '*' and ',' are not supported in label filters."); + } + return selector; + }); +} + +function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] { + if (selectors === undefined || selectors.length === 0) { + // Default selector: key: *, label: \0 + return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; + } + return getValidSelectors(selectors); +} + +function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] { + if (selectors === undefined || selectors.length === 0) { + // Default selector: key: *, label: \0 + return [{ keyFilter: `${featureFlagPrefix}${KeyFilter.Any}`, labelFilter: LabelFilter.Null }]; + } + selectors.forEach(selector => { + selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`; + }); + return getValidSelectors(selectors); +} diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index dcf27765..0ebcbd4f 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -1,69 +1,69 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { AppConfigurationClientOptions } from "@azure/app-configuration"; -import { KeyVaultOptions } from "./keyvault/KeyVaultOptions.js"; -import { RefreshOptions } from "./refresh/refreshOptions.js"; -import { SettingSelector } from "./types.js"; -import { FeatureFlagOptions } from "./featureManagement/FeatureFlagOptions.js"; -import { StartupOptions } from "./StartupOptions.js"; - -export interface AzureAppConfigurationOptions { - /** - * Specifies 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. - */ - trimKeyPrefixes?: string[]; - - /** - * Specifies custom options to be used when creating the AppConfigurationClient. - */ - clientOptions?: AppConfigurationClientOptions; - - /** - * Specifies options used to resolve Vey Vault references. - */ - keyVaultOptions?: KeyVaultOptions; - - /** - * Specifies options for dynamic refresh key-values. - */ - refreshOptions?: RefreshOptions; - - /** - * Specifies options used to configure feature flags. - */ - featureFlagOptions?: FeatureFlagOptions; - - /** - * Specifies options used to configure provider startup. - */ - startupOptions?: StartupOptions; - - /** - * Specifies whether to enable replica discovery or not. - * - * @remarks - * If not specified, the default value is true. - */ - replicaDiscoveryEnabled?: boolean; - - /** - * Specifies whether to enable load balance or not. - * - * @remarks - * If not specified, the default value is false. - */ - loadBalancingEnabled?: boolean; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AppConfigurationClientOptions } from "@azure/app-configuration"; +import { KeyVaultOptions } from "./keyvault/KeyVaultOptions.js"; +import { RefreshOptions } from "./refresh/refreshOptions.js"; +import { SettingSelector } from "./types.js"; +import { FeatureFlagOptions } from "./featureManagement/FeatureFlagOptions.js"; +import { StartupOptions } from "./StartupOptions.js"; + +export interface AzureAppConfigurationOptions { + /** + * Specifies 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. + */ + trimKeyPrefixes?: string[]; + + /** + * Specifies custom options to be used when creating the AppConfigurationClient. + */ + clientOptions?: AppConfigurationClientOptions; + + /** + * Specifies options used to resolve Vey Vault references. + */ + keyVaultOptions?: KeyVaultOptions; + + /** + * Specifies options for dynamic refresh key-values. + */ + refreshOptions?: RefreshOptions; + + /** + * Specifies options used to configure feature flags. + */ + featureFlagOptions?: FeatureFlagOptions; + + /** + * Specifies options used to configure provider startup. + */ + startupOptions?: StartupOptions; + + /** + * Specifies whether to enable replica discovery or not. + * + * @remarks + * If not specified, the default value is true. + */ + replicaDiscoveryEnabled?: boolean; + + /** + * Specifies whether to enable load balance or not. + * + * @remarks + * If not specified, the default value is false. + */ + loadBalancingEnabled?: boolean; +} diff --git a/src/IKeyValueAdapter.ts b/src/IKeyValueAdapter.ts index afd7bdc3..1f5042d6 100644 --- a/src/IKeyValueAdapter.ts +++ b/src/IKeyValueAdapter.ts @@ -1,16 +1,16 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -import { ConfigurationSetting } from "@azure/app-configuration"; - -export interface IKeyValueAdapter { - /** - * Determine whether the adapter applies to a configuration setting. - * Note: A setting is expected to be processed by at most one adapter. - */ - canProcess(setting: ConfigurationSetting): boolean; - - /** - * This method process the original configuration setting, and returns processed key and value in an array. - */ - processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { ConfigurationSetting } from "@azure/app-configuration"; + +export interface IKeyValueAdapter { + /** + * Determine whether the adapter applies to a configuration setting. + * Note: A setting is expected to be processed by at most one adapter. + */ + canProcess(setting: ConfigurationSetting): boolean; + + /** + * This method process the original configuration setting, and returns processed key and value in an array. + */ + processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>; } diff --git a/src/JsonKeyValueAdapter.ts b/src/JsonKeyValueAdapter.ts index dcce1033..92f52f53 100644 --- a/src/JsonKeyValueAdapter.ts +++ b/src/JsonKeyValueAdapter.ts @@ -1,38 +1,38 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { ConfigurationSetting, featureFlagContentType, secretReferenceContentType } from "@azure/app-configuration"; -import { parseContentType, isJsonContentType } from "./common/contentType.js"; -import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; - -export class JsonKeyValueAdapter implements IKeyValueAdapter { - static readonly #ExcludedJsonContentTypes: string[] = [ - secretReferenceContentType, - featureFlagContentType - ]; - - canProcess(setting: ConfigurationSetting): boolean { - if (!setting.contentType) { - return false; - } - if (JsonKeyValueAdapter.#ExcludedJsonContentTypes.includes(setting.contentType)) { - return false; - } - const contentType = parseContentType(setting.contentType); - return isJsonContentType(contentType); - } - - async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { - let parsedValue: unknown; - if (setting.value !== undefined) { - try { - parsedValue = JSON.parse(setting.value); - } catch (error) { - parsedValue = setting.value; - } - } else { - parsedValue = setting.value; - } - return [setting.key, parsedValue]; - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ConfigurationSetting, featureFlagContentType, secretReferenceContentType } from "@azure/app-configuration"; +import { parseContentType, isJsonContentType } from "./common/contentType.js"; +import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; + +export class JsonKeyValueAdapter implements IKeyValueAdapter { + static readonly #ExcludedJsonContentTypes: string[] = [ + secretReferenceContentType, + featureFlagContentType + ]; + + canProcess(setting: ConfigurationSetting): boolean { + if (!setting.contentType) { + return false; + } + if (JsonKeyValueAdapter.#ExcludedJsonContentTypes.includes(setting.contentType)) { + return false; + } + const contentType = parseContentType(setting.contentType); + return isJsonContentType(contentType); + } + + async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { + let parsedValue: unknown; + if (setting.value !== undefined) { + try { + parsedValue = JSON.parse(setting.value); + } catch (error) { + parsedValue = setting.value; + } + } else { + parsedValue = setting.value; + } + return [setting.key, parsedValue]; + } +} diff --git a/src/WatchedSetting.ts b/src/WatchedSetting.ts index 40f4d4fb..6c05da3d 100644 --- a/src/WatchedSetting.ts +++ b/src/WatchedSetting.ts @@ -1,18 +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; +// 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; } diff --git a/src/common/disposable.ts b/src/common/disposable.ts index 13d0aeba..c28b2a55 100644 --- a/src/common/disposable.ts +++ b/src/common/disposable.ts @@ -1,18 +1,18 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export class Disposable { - #disposed = false; - #callOnDispose: () => any; - - constructor(callOnDispose: () => any) { - this.#callOnDispose = callOnDispose; - } - - dispose() { - if (!this.#disposed) { - this.#callOnDispose(); - } - this.#disposed = true; - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export class Disposable { + #disposed = false; + #callOnDispose: () => any; + + constructor(callOnDispose: () => any) { + this.#callOnDispose = callOnDispose; + } + + dispose() { + if (!this.#disposed) { + this.#callOnDispose(); + } + this.#disposed = true; + } +} diff --git a/src/featureManagement/FeatureFlagOptions.ts b/src/featureManagement/FeatureFlagOptions.ts index 6814dbf3..37612a1a 100644 --- a/src/featureManagement/FeatureFlagOptions.ts +++ b/src/featureManagement/FeatureFlagOptions.ts @@ -1,29 +1,29 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { FeatureFlagRefreshOptions } from "../refresh/refreshOptions.js"; -import { SettingSelector } from "../types.js"; - -/** - * Options used to configure feature flags. - */ -export interface FeatureFlagOptions { - /** - * Specifies whether feature flags will be loaded from Azure App Configuration. - */ - enabled: boolean; - - /** - * Specifies what feature flags to include in the configuration provider. - * - * @remarks - * keyFilter of selector will be prefixed with "appconfig.featureflag/" when request is sent. - * If no selectors are specified then all feature flags with no label will be included. - */ - selectors?: SettingSelector[]; - - /** - * Specifies how feature flag refresh is configured. All selected feature flags will be watched for changes. - */ - refresh?: FeatureFlagRefreshOptions; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { FeatureFlagRefreshOptions } from "../refresh/refreshOptions.js"; +import { SettingSelector } from "../types.js"; + +/** + * Options used to configure feature flags. + */ +export interface FeatureFlagOptions { + /** + * Specifies whether feature flags will be loaded from Azure App Configuration. + */ + enabled: boolean; + + /** + * Specifies what feature flags to include in the configuration provider. + * + * @remarks + * keyFilter of selector will be prefixed with "appconfig.featureflag/" when request is sent. + * If no selectors are specified then all feature flags with no label will be included. + */ + selectors?: SettingSelector[]; + + /** + * Specifies how feature flag refresh is configured. All selected feature flags will be watched for changes. + */ + refresh?: FeatureFlagRefreshOptions; +} diff --git a/src/featureManagement/constants.ts b/src/featureManagement/constants.ts index eb3282dd..ac1bf02e 100644 --- a/src/featureManagement/constants.ts +++ b/src/featureManagement/constants.ts @@ -1,26 +1,26 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export const FEATURE_MANAGEMENT_KEY_NAME = "feature_management"; -export const FEATURE_FLAGS_KEY_NAME = "feature_flags"; -export const NAME_KEY_NAME = "name"; -export const TELEMETRY_KEY_NAME = "telemetry"; -export const ENABLED_KEY_NAME = "enabled"; -export const METADATA_KEY_NAME = "metadata"; -export const ETAG_KEY_NAME = "ETag"; -export const FEATURE_FLAG_REFERENCE_KEY_NAME = "FeatureFlagReference"; -export const ALLOCATION_KEY_NAME = "allocation"; -export const DEFAULT_WHEN_ENABLED_KEY_NAME = "default_when_enabled"; -export const PERCENTILE_KEY_NAME = "percentile"; -export const FROM_KEY_NAME = "from"; -export const TO_KEY_NAME = "to"; -export const SEED_KEY_NAME = "seed"; -export const VARIANT_KEY_NAME = "variant"; -export const VARIANTS_KEY_NAME = "variants"; -export const CONFIGURATION_VALUE_KEY_NAME = "configuration_value"; -export const ALLOCATION_ID_KEY_NAME = "AllocationId"; -export const CONDITIONS_KEY_NAME = "conditions"; -export const CLIENT_FILTERS_KEY_NAME = "client_filters"; - -export const TIME_WINDOW_FILTER_NAMES = ["TimeWindow", "Microsoft.TimeWindow", "TimeWindowFilter", "Microsoft.TimeWindowFilter"]; -export const TARGETING_FILTER_NAMES = ["Targeting", "Microsoft.Targeting", "TargetingFilter", "Microsoft.TargetingFilter"]; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const FEATURE_MANAGEMENT_KEY_NAME = "feature_management"; +export const FEATURE_FLAGS_KEY_NAME = "feature_flags"; +export const NAME_KEY_NAME = "name"; +export const TELEMETRY_KEY_NAME = "telemetry"; +export const ENABLED_KEY_NAME = "enabled"; +export const METADATA_KEY_NAME = "metadata"; +export const ETAG_KEY_NAME = "ETag"; +export const FEATURE_FLAG_REFERENCE_KEY_NAME = "FeatureFlagReference"; +export const ALLOCATION_KEY_NAME = "allocation"; +export const DEFAULT_WHEN_ENABLED_KEY_NAME = "default_when_enabled"; +export const PERCENTILE_KEY_NAME = "percentile"; +export const FROM_KEY_NAME = "from"; +export const TO_KEY_NAME = "to"; +export const SEED_KEY_NAME = "seed"; +export const VARIANT_KEY_NAME = "variant"; +export const VARIANTS_KEY_NAME = "variants"; +export const CONFIGURATION_VALUE_KEY_NAME = "configuration_value"; +export const ALLOCATION_ID_KEY_NAME = "AllocationId"; +export const CONDITIONS_KEY_NAME = "conditions"; +export const CLIENT_FILTERS_KEY_NAME = "client_filters"; + +export const TIME_WINDOW_FILTER_NAMES = ["TimeWindow", "Microsoft.TimeWindow", "TimeWindowFilter", "Microsoft.TimeWindowFilter"]; +export const TARGETING_FILTER_NAMES = ["Targeting", "Microsoft.Targeting", "TargetingFilter", "Microsoft.TargetingFilter"]; diff --git a/src/index.ts b/src/index.ts index 1a3bb318..92836b9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export { AzureAppConfiguration } from "./AzureAppConfiguration.js"; -export { Disposable } from "./common/disposable.js"; -export { load } from "./load.js"; -export { KeyFilter, LabelFilter } from "./types.js"; -export { VERSION } from "./version.js"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { AzureAppConfiguration } from "./AzureAppConfiguration.js"; +export { Disposable } from "./common/disposable.js"; +export { load } from "./load.js"; +export { KeyFilter, LabelFilter } from "./types.js"; +export { VERSION } from "./version.js"; diff --git a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts index 41a1ac89..d67fee34 100644 --- a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -1,94 +1,94 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration"; -import { IKeyValueAdapter } from "../IKeyValueAdapter.js"; -import { KeyVaultOptions } from "./KeyVaultOptions.js"; -import { ArgumentError, KeyVaultReferenceError } from "../common/error.js"; -import { KeyVaultSecretIdentifier, SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; -import { isRestError } from "@azure/core-rest-pipeline"; -import { AuthenticationError } from "@azure/identity"; - -export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { - /** - * Map vault hostname to corresponding secret client. - */ - #secretClients: Map; - #keyVaultOptions: KeyVaultOptions | undefined; - - constructor(keyVaultOptions: KeyVaultOptions | undefined) { - this.#keyVaultOptions = keyVaultOptions; - } - - canProcess(setting: ConfigurationSetting): boolean { - return isSecretReference(setting); - } - - async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { - // TODO: cache results to save requests. - if (!this.#keyVaultOptions) { - throw new ArgumentError("Failed to process the Key Vault reference because Key Vault options are not configured."); - } - let secretIdentifier: KeyVaultSecretIdentifier; - try { - secretIdentifier = parseKeyVaultSecretIdentifier( - parseSecretReference(setting).value.secretId - ); - } catch (error) { - throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", setting), { cause: error }); - } - - try { - // precedence: secret clients > credential > secret resolver - const client = this.#getSecretClient(new URL(secretIdentifier.vaultUrl)); - if (client) { - const secret = await client.getSecret(secretIdentifier.name, { version: secretIdentifier.version }); - return [setting.key, secret.value]; - } - if (this.#keyVaultOptions.secretResolver) { - return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(secretIdentifier.sourceId))]; - } - } catch (error) { - if (isRestError(error) || error instanceof AuthenticationError) { - throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", setting, secretIdentifier.sourceId), { cause: error }); - } - throw error; - } - - // When code reaches here, it means that the key vault reference cannot be resolved in all possible ways. - throw new ArgumentError("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); - } - - /** - * - * @param vaultUrl - The url of the key vault. - * @returns - */ - #getSecretClient(vaultUrl: URL): SecretClient | undefined { - if (this.#secretClients === undefined) { - this.#secretClients = new Map(); - for (const client of this.#keyVaultOptions?.secretClients ?? []) { - const clientUrl = new URL(client.vaultUrl); - this.#secretClients.set(clientUrl.host, client); - } - } - - let client: SecretClient | undefined; - client = this.#secretClients.get(vaultUrl.host); - if (client !== undefined) { - return client; - } - - if (this.#keyVaultOptions?.credential) { - client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential, this.#keyVaultOptions.clientOptions); - this.#secretClients.set(vaultUrl.host, client); - return client; - } - - return undefined; - } -} - -function buildKeyVaultReferenceErrorMessage(message: string, setting: ConfigurationSetting, secretIdentifier?: string ): string { - return `${message} Key: '${setting.key}' Label: '${setting.label ?? ""}' ETag: '${setting.etag ?? ""}' ${secretIdentifier ? ` SecretIdentifier: '${secretIdentifier}'` : ""}`; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration"; +import { IKeyValueAdapter } from "../IKeyValueAdapter.js"; +import { KeyVaultOptions } from "./KeyVaultOptions.js"; +import { ArgumentError, KeyVaultReferenceError } from "../common/error.js"; +import { KeyVaultSecretIdentifier, SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; +import { isRestError } from "@azure/core-rest-pipeline"; +import { AuthenticationError } from "@azure/identity"; + +export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { + /** + * Map vault hostname to corresponding secret client. + */ + #secretClients: Map; + #keyVaultOptions: KeyVaultOptions | undefined; + + constructor(keyVaultOptions: KeyVaultOptions | undefined) { + this.#keyVaultOptions = keyVaultOptions; + } + + canProcess(setting: ConfigurationSetting): boolean { + return isSecretReference(setting); + } + + async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { + // TODO: cache results to save requests. + if (!this.#keyVaultOptions) { + throw new ArgumentError("Failed to process the Key Vault reference because Key Vault options are not configured."); + } + let secretIdentifier: KeyVaultSecretIdentifier; + try { + secretIdentifier = parseKeyVaultSecretIdentifier( + parseSecretReference(setting).value.secretId + ); + } catch (error) { + throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", setting), { cause: error }); + } + + try { + // precedence: secret clients > credential > secret resolver + const client = this.#getSecretClient(new URL(secretIdentifier.vaultUrl)); + if (client) { + const secret = await client.getSecret(secretIdentifier.name, { version: secretIdentifier.version }); + return [setting.key, secret.value]; + } + if (this.#keyVaultOptions.secretResolver) { + return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(secretIdentifier.sourceId))]; + } + } catch (error) { + if (isRestError(error) || error instanceof AuthenticationError) { + throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", setting, secretIdentifier.sourceId), { cause: error }); + } + throw error; + } + + // When code reaches here, it means that the key vault reference cannot be resolved in all possible ways. + throw new ArgumentError("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); + } + + /** + * + * @param vaultUrl - The url of the key vault. + * @returns + */ + #getSecretClient(vaultUrl: URL): SecretClient | undefined { + if (this.#secretClients === undefined) { + this.#secretClients = new Map(); + for (const client of this.#keyVaultOptions?.secretClients ?? []) { + const clientUrl = new URL(client.vaultUrl); + this.#secretClients.set(clientUrl.host, client); + } + } + + let client: SecretClient | undefined; + client = this.#secretClients.get(vaultUrl.host); + if (client !== undefined) { + return client; + } + + if (this.#keyVaultOptions?.credential) { + client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential, this.#keyVaultOptions.clientOptions); + this.#secretClients.set(vaultUrl.host, client); + return client; + } + + return undefined; + } +} + +function buildKeyVaultReferenceErrorMessage(message: string, setting: ConfigurationSetting, secretIdentifier?: string ): string { + return `${message} Key: '${setting.key}' Label: '${setting.label ?? ""}' ETag: '${setting.etag ?? ""}' ${secretIdentifier ? ` SecretIdentifier: '${secretIdentifier}'` : ""}`; +} diff --git a/src/keyvault/KeyVaultOptions.ts b/src/keyvault/KeyVaultOptions.ts index f51d9699..132c9cf5 100644 --- a/src/keyvault/KeyVaultOptions.ts +++ b/src/keyvault/KeyVaultOptions.ts @@ -1,35 +1,35 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { TokenCredential } from "@azure/identity"; -import { SecretClient, SecretClientOptions } from "@azure/keyvault-secrets"; - -/** - * Options used to resolve Key Vault references. - */ -export interface KeyVaultOptions { - /** - * Specifies the Key Vault secret client used for resolving Key Vault references. - */ - secretClients?: SecretClient[]; - - /** - * Specifies the credentials used to authenticate to key vaults that have no applied SecretClient. - */ - credential?: TokenCredential; - - /** - * Configures the client options used when connecting to key vaults that have no registered SecretClient. - * - * @remarks - * The client options will not affect the registered SecretClient instances. - */ - clientOptions?: SecretClientOptions; - - /** - * Specifies the callback used to resolve key vault references that have no applied SecretClient. - * @param keyVaultReference The Key Vault reference to resolve. - * @returns The secret value. - */ - secretResolver?: (keyVaultReference: URL) => string | Promise; -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TokenCredential } from "@azure/identity"; +import { SecretClient, SecretClientOptions } from "@azure/keyvault-secrets"; + +/** + * Options used to resolve Key Vault references. + */ +export interface KeyVaultOptions { + /** + * Specifies the Key Vault secret client used for resolving Key Vault references. + */ + secretClients?: SecretClient[]; + + /** + * Specifies the credentials used to authenticate to key vaults that have no applied SecretClient. + */ + credential?: TokenCredential; + + /** + * Configures the client options used when connecting to key vaults that have no registered SecretClient. + * + * @remarks + * The client options will not affect the registered SecretClient instances. + */ + clientOptions?: SecretClientOptions; + + /** + * Specifies the callback used to resolve key vault references that have no applied SecretClient. + * @param keyVaultReference The Key Vault reference to resolve. + * @returns The secret value. + */ + secretResolver?: (keyVaultReference: URL) => string | Promise; +} diff --git a/src/load.ts b/src/load.ts index 2046b064..25fd9594 100644 --- a/src/load.ts +++ b/src/load.ts @@ -1,58 +1,58 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { TokenCredential } from "@azure/identity"; -import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; -import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; -import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; -import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; -import { instanceOfTokenCredential } from "./common/utils.js"; - -const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5_000; // 5 seconds - -/** - * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. - * @param connectionString The connection string for the App Configuration store. - * @param options Optional parameters. - */ -export async function load(connectionString: string, options?: AzureAppConfigurationOptions): Promise; - -/** - * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. - * @param endpoint The URL to the App Configuration store. - * @param credential The credential to use to connect to the App Configuration store. - * @param options Optional parameters. - */ -export async function load(endpoint: URL | string, credential: TokenCredential, options?: AzureAppConfigurationOptions): Promise; - -export async function load( - connectionStringOrEndpoint: string | URL, - credentialOrOptions?: TokenCredential | AzureAppConfigurationOptions, - appConfigOptions?: AzureAppConfigurationOptions -): Promise { - const startTimestamp = Date.now(); - let options: AzureAppConfigurationOptions | undefined; - const clientManager = new ConfigurationClientManager(connectionStringOrEndpoint, credentialOrOptions, appConfigOptions); - await clientManager.init(); - - if (!instanceOfTokenCredential(credentialOrOptions)) { - options = credentialOrOptions as AzureAppConfigurationOptions; - } else { - options = appConfigOptions; - } - - try { - const appConfiguration = new AzureAppConfigurationImpl(clientManager, 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 = MIN_DELAY_FOR_UNHANDLED_ERROR - (Date.now() - startTimestamp); - if (delay > 0) { - await new Promise((resolve) => setTimeout(resolve, delay)); - } - throw error; - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TokenCredential } from "@azure/identity"; +import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; +import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; +import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; +import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; +import { instanceOfTokenCredential } from "./common/utils.js"; + +const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5_000; // 5 seconds + +/** + * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. + * @param connectionString The connection string for the App Configuration store. + * @param options Optional parameters. + */ +export async function load(connectionString: string, options?: AzureAppConfigurationOptions): Promise; + +/** + * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. + * @param endpoint The URL to the App Configuration store. + * @param credential The credential to use to connect to the App Configuration store. + * @param options Optional parameters. + */ +export async function load(endpoint: URL | string, credential: TokenCredential, options?: AzureAppConfigurationOptions): Promise; + +export async function load( + connectionStringOrEndpoint: string | URL, + credentialOrOptions?: TokenCredential | AzureAppConfigurationOptions, + appConfigOptions?: AzureAppConfigurationOptions +): Promise { + const startTimestamp = Date.now(); + let options: AzureAppConfigurationOptions | undefined; + const clientManager = new ConfigurationClientManager(connectionStringOrEndpoint, credentialOrOptions, appConfigOptions); + await clientManager.init(); + + if (!instanceOfTokenCredential(credentialOrOptions)) { + options = credentialOrOptions as AzureAppConfigurationOptions; + } else { + options = appConfigOptions; + } + + try { + const appConfiguration = new AzureAppConfigurationImpl(clientManager, 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 = MIN_DELAY_FOR_UNHANDLED_ERROR - (Date.now() - startTimestamp); + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + throw error; + } +} diff --git a/src/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts index 5dff67fd..cf4deca5 100644 --- a/src/refresh/RefreshTimer.ts +++ b/src/refresh/RefreshTimer.ts @@ -1,24 +1,24 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export class RefreshTimer { - #backoffEnd: number; // Timestamp - #interval: number; - - constructor(interval: number) { - if (interval <= 0) { - throw new RangeError(`Refresh interval must be greater than 0. Given: ${interval}`); - } - - this.#interval = interval; - this.#backoffEnd = Date.now() + this.#interval; - } - - canRefresh(): boolean { - return Date.now() >= this.#backoffEnd; - } - - reset(): void { - this.#backoffEnd = Date.now() + this.#interval; - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export class RefreshTimer { + #backoffEnd: number; // Timestamp + #interval: number; + + constructor(interval: number) { + if (interval <= 0) { + throw new RangeError(`Refresh interval must be greater than 0. Given: ${interval}`); + } + + this.#interval = interval; + this.#backoffEnd = Date.now() + this.#interval; + } + + canRefresh(): boolean { + return Date.now() >= this.#backoffEnd; + } + + reset(): void { + this.#backoffEnd = Date.now() + this.#interval; + } +} diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index 6d1f3f3f..cfed8317 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -1,80 +1,80 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { VERSION } from "../version.js"; - -export const ENV_AZURE_APP_CONFIGURATION_TRACING_DISABLED = "AZURE_APP_CONFIGURATION_TRACING_DISABLED"; - -// User Agent -export const USER_AGENT_PREFIX = `javascript-appconfiguration-provider/${VERSION}`; - -// Correlation Context -export const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; - -// Env -export const NODEJS_ENV_VAR = "NODE_ENV"; -export const NODEJS_DEV_ENV_VAL = "development"; -export const ENV_KEY = "Env"; -export const DEV_ENV_VAL = "Dev"; - -// Host Type -export const HOST_TYPE_KEY = "Host"; -export enum HostType { - AZURE_FUNCTION = "AzureFunction", - AZURE_WEB_APP = "AzureWebApp", - CONTAINER_APP = "ContainerApp", - KUBERNETES = "Kubernetes", - SERVICE_FABRIC = "ServiceFabric", - // Client-side - BROWSER = "Web", - WEB_WORKER = "WebWorker" -} - -// Environment variables to identify Host type. -export const AZURE_FUNCTION_ENV_VAR = "FUNCTIONS_EXTENSION_VERSION"; -export const AZURE_WEB_APP_ENV_VAR = "WEBSITE_SITE_NAME"; -export const CONTAINER_APP_ENV_VAR = "CONTAINER_APP_NAME"; -export const KUBERNETES_ENV_VAR = "KUBERNETES_PORT"; -export const SERVICE_FABRIC_ENV_VAR = "Fabric_NodeName"; // See: https://docs.microsoft.com/en-us/azure/service-fabric/service-fabric-environment-variables-reference - -// Request type -export const REQUEST_TYPE_KEY = "RequestType"; -export enum RequestType { - STARTUP = "Startup", - WATCH = "Watch" -} - -// Replica count -export const REPLICA_COUNT_KEY = "ReplicaCount"; - -// Tag names -export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; -export const FAILOVER_REQUEST_TAG = "Failover"; - -// Compact feature tags -export const FEATURES_KEY = "Features"; -export const LOAD_BALANCE_CONFIGURED_TAG = "LB"; - -// Feature management package -export const FM_PACKAGE_NAME = "@microsoft/feature-management"; -export const FM_VERSION_KEY = "FMJsVer"; - -// Feature flag usage tracing -export const FEATURE_FILTER_TYPE_KEY = "Filter"; -export const CUSTOM_FILTER_KEY = "CSTM"; -export const TIME_WINDOW_FILTER_KEY = "TIME"; -export const TARGETING_FILTER_KEY = "TRGT"; - -export const FF_TELEMETRY_USED_TAG = "Telemetry"; -export const FF_MAX_VARIANTS_KEY = "MaxVariants"; -export const FF_SEED_USED_TAG = "Seed"; -export const FF_FEATURES_KEY = "FFFeatures"; - -// AI Configuration tracing -export const AI_CONFIGURATION_TAG = "AI"; -export const AI_CHAT_COMPLETION_CONFIGURATION_TAG = "AICC"; - -export const AI_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai"; -export const AI_CHAT_COMPLETION_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai/chat-completion"; - -export const DELIMITER = "+"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { VERSION } from "../version.js"; + +export const ENV_AZURE_APP_CONFIGURATION_TRACING_DISABLED = "AZURE_APP_CONFIGURATION_TRACING_DISABLED"; + +// User Agent +export const USER_AGENT_PREFIX = `javascript-appconfiguration-provider/${VERSION}`; + +// Correlation Context +export const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; + +// Env +export const NODEJS_ENV_VAR = "NODE_ENV"; +export const NODEJS_DEV_ENV_VAL = "development"; +export const ENV_KEY = "Env"; +export const DEV_ENV_VAL = "Dev"; + +// Host Type +export const HOST_TYPE_KEY = "Host"; +export enum HostType { + AZURE_FUNCTION = "AzureFunction", + AZURE_WEB_APP = "AzureWebApp", + CONTAINER_APP = "ContainerApp", + KUBERNETES = "Kubernetes", + SERVICE_FABRIC = "ServiceFabric", + // Client-side + BROWSER = "Web", + WEB_WORKER = "WebWorker" +} + +// Environment variables to identify Host type. +export const AZURE_FUNCTION_ENV_VAR = "FUNCTIONS_EXTENSION_VERSION"; +export const AZURE_WEB_APP_ENV_VAR = "WEBSITE_SITE_NAME"; +export const CONTAINER_APP_ENV_VAR = "CONTAINER_APP_NAME"; +export const KUBERNETES_ENV_VAR = "KUBERNETES_PORT"; +export const SERVICE_FABRIC_ENV_VAR = "Fabric_NodeName"; // See: https://docs.microsoft.com/en-us/azure/service-fabric/service-fabric-environment-variables-reference + +// Request type +export const REQUEST_TYPE_KEY = "RequestType"; +export enum RequestType { + STARTUP = "Startup", + WATCH = "Watch" +} + +// Replica count +export const REPLICA_COUNT_KEY = "ReplicaCount"; + +// Tag names +export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; +export const FAILOVER_REQUEST_TAG = "Failover"; + +// Compact feature tags +export const FEATURES_KEY = "Features"; +export const LOAD_BALANCE_CONFIGURED_TAG = "LB"; + +// Feature management package +export const FM_PACKAGE_NAME = "@microsoft/feature-management"; +export const FM_VERSION_KEY = "FMJsVer"; + +// Feature flag usage tracing +export const FEATURE_FILTER_TYPE_KEY = "Filter"; +export const CUSTOM_FILTER_KEY = "CSTM"; +export const TIME_WINDOW_FILTER_KEY = "TIME"; +export const TARGETING_FILTER_KEY = "TRGT"; + +export const FF_TELEMETRY_USED_TAG = "Telemetry"; +export const FF_MAX_VARIANTS_KEY = "MaxVariants"; +export const FF_SEED_USED_TAG = "Seed"; +export const FF_FEATURES_KEY = "FFFeatures"; + +// AI Configuration tracing +export const AI_CONFIGURATION_TAG = "AI"; +export const AI_CHAT_COMPLETION_CONFIGURATION_TAG = "AICC"; + +export const AI_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai"; +export const AI_CHAT_COMPLETION_MIME_PROFILE = "https://azconfig.io/mime-profiles/ai/chat-completion"; + +export const DELIMITER = "+"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index 9332c656..6abd4497 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -1,230 +1,230 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions } from "@azure/app-configuration"; -import { AzureAppConfigurationOptions } from "../AzureAppConfigurationOptions.js"; -import { FeatureFlagTracingOptions } from "./FeatureFlagTracingOptions.js"; -import { AIConfigurationTracingOptions } from "./AIConfigurationTracingOptions.js"; -import { - AZURE_FUNCTION_ENV_VAR, - AZURE_WEB_APP_ENV_VAR, - CONTAINER_APP_ENV_VAR, - DEV_ENV_VAL, - ENV_AZURE_APP_CONFIGURATION_TRACING_DISABLED, - ENV_KEY, - FEATURE_FILTER_TYPE_KEY, - FF_MAX_VARIANTS_KEY, - FF_FEATURES_KEY, - HOST_TYPE_KEY, - HostType, - KEY_VAULT_CONFIGURED_TAG, - KUBERNETES_ENV_VAR, - NODEJS_DEV_ENV_VAL, - NODEJS_ENV_VAR, - REQUEST_TYPE_KEY, - RequestType, - SERVICE_FABRIC_ENV_VAR, - CORRELATION_CONTEXT_HEADER_NAME, - REPLICA_COUNT_KEY, - FAILOVER_REQUEST_TAG, - FEATURES_KEY, - LOAD_BALANCE_CONFIGURED_TAG, - FM_VERSION_KEY, - DELIMITER, - AI_CONFIGURATION_TAG, - AI_CHAT_COMPLETION_CONFIGURATION_TAG -} from "./constants"; - -export interface RequestTracingOptions { - enabled: boolean; - appConfigOptions: AzureAppConfigurationOptions | undefined; - initialLoadCompleted: boolean; - replicaCount: number; - isFailoverRequest: boolean; - featureFlagTracing: FeatureFlagTracingOptions | undefined; - fmVersion: string | undefined; - aiConfigurationTracing: AIConfigurationTracingOptions | undefined; -} - -// Utils -export function listConfigurationSettingsWithTrace( - requestTracingOptions: RequestTracingOptions, - client: AppConfigurationClient, - listOptions: ListConfigurationSettingsOptions -) { - const actualListOptions = { ...listOptions }; - if (requestTracingOptions.enabled) { - actualListOptions.requestOptions = { - customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) - } - }; - } - - return client.listConfigurationSettings(actualListOptions); -} - -export function getConfigurationSettingWithTrace( - requestTracingOptions: RequestTracingOptions, - client: AppConfigurationClient, - configurationSettingId: ConfigurationSettingId, - getOptions?: GetConfigurationSettingOptions, -) { - const actualGetOptions = { ...getOptions }; - - if (requestTracingOptions.enabled) { - actualGetOptions.requestOptions = { - customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) - } - }; - } - - return client.getConfigurationSetting(configurationSettingId, actualGetOptions); -} - -export function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string { - /* - RequestType: 'Startup' during application starting up, 'Watch' after startup completed. - Host: identify with defined envs - Env: identify by env `NODE_ENV` which is a popular but not standard. Usually, the value can be "development", "production". - ReplicaCount: identify how many replicas are found - Features: LB - Filter: CSTM+TIME+TRGT - MaxVariants: identify the max number of variants feature flag uses - FFFeatures: Seed+Telemetry - UsersKeyVault - Failover - */ - const keyValues = new Map(); - const tags: string[] = []; - - keyValues.set(REQUEST_TYPE_KEY, requestTracingOptions.initialLoadCompleted ? RequestType.WATCH : RequestType.STARTUP); - keyValues.set(HOST_TYPE_KEY, getHostType()); - keyValues.set(ENV_KEY, isDevEnvironment() ? DEV_ENV_VAL : undefined); - - const appConfigOptions = requestTracingOptions.appConfigOptions; - if (appConfigOptions?.keyVaultOptions) { - const { credential, secretClients, secretResolver } = appConfigOptions.keyVaultOptions; - if (credential !== undefined || secretClients?.length || secretResolver !== undefined) { - tags.push(KEY_VAULT_CONFIGURED_TAG); - } - } - - const featureFlagTracing = requestTracingOptions.featureFlagTracing; - if (featureFlagTracing) { - keyValues.set(FEATURE_FILTER_TYPE_KEY, featureFlagTracing.usesAnyFeatureFilter() ? featureFlagTracing.createFeatureFiltersString() : undefined); - keyValues.set(FF_FEATURES_KEY, featureFlagTracing.usesAnyTracingFeature() ? featureFlagTracing.createFeaturesString() : undefined); - if (featureFlagTracing.maxVariants > 0) { - keyValues.set(FF_MAX_VARIANTS_KEY, featureFlagTracing.maxVariants.toString()); - } - } - - if (requestTracingOptions.isFailoverRequest) { - tags.push(FAILOVER_REQUEST_TAG); - } - if (requestTracingOptions.replicaCount > 0) { - keyValues.set(REPLICA_COUNT_KEY, requestTracingOptions.replicaCount.toString()); - } - if (requestTracingOptions.fmVersion) { - keyValues.set(FM_VERSION_KEY, requestTracingOptions.fmVersion); - } - - // Use compact tags for new tracing features: Features=LB+AI+AICC... - keyValues.set(FEATURES_KEY, usesAnyTracingFeature(requestTracingOptions) ? createFeaturesString(requestTracingOptions) : undefined); - - const contextParts: string[] = []; - for (const [key, value] of keyValues) { - if (value !== undefined) { - contextParts.push(`${key}=${value}`); - } - } - for (const tag of tags) { - contextParts.push(tag); - } - - return contextParts.join(","); -} - -export function requestTracingEnabled(): boolean { - const requestTracingDisabledEnv = getEnvironmentVariable(ENV_AZURE_APP_CONFIGURATION_TRACING_DISABLED); - const disabled = requestTracingDisabledEnv?.toLowerCase() === "true"; - return !disabled; -} - -function usesAnyTracingFeature(requestTracingOptions: RequestTracingOptions): boolean { - return (requestTracingOptions.appConfigOptions?.loadBalancingEnabled ?? false) || - (requestTracingOptions.aiConfigurationTracing?.usesAnyTracingFeature() ?? false); -} - -function createFeaturesString(requestTracingOptions: RequestTracingOptions): string { - const tags: string[] = []; - if (requestTracingOptions.appConfigOptions?.loadBalancingEnabled) { - tags.push(LOAD_BALANCE_CONFIGURED_TAG); - } - if (requestTracingOptions.aiConfigurationTracing?.usesAIConfiguration) { - tags.push(AI_CONFIGURATION_TAG); - } - if (requestTracingOptions.aiConfigurationTracing?.usesAIChatCompletionConfiguration) { - tags.push(AI_CHAT_COMPLETION_CONFIGURATION_TAG); - } - return tags.join(DELIMITER); -} - -function getEnvironmentVariable(name: string) { - // Make it compatible with non-Node.js runtime - if (typeof process !== "undefined" && typeof process?.env === "object") { - return process.env[name]; - } else { - return undefined; - } -} - -function getHostType(): string | undefined { - let hostType: string | undefined; - if (getEnvironmentVariable(AZURE_FUNCTION_ENV_VAR)) { - hostType = HostType.AZURE_FUNCTION; - } else if (getEnvironmentVariable(AZURE_WEB_APP_ENV_VAR)) { - hostType = HostType.AZURE_WEB_APP; - } else if (getEnvironmentVariable(CONTAINER_APP_ENV_VAR)) { - hostType = HostType.CONTAINER_APP; - } else if (getEnvironmentVariable(KUBERNETES_ENV_VAR)) { - hostType = HostType.KUBERNETES; - } else if (getEnvironmentVariable(SERVICE_FABRIC_ENV_VAR)) { - hostType = HostType.SERVICE_FABRIC; - } else if (isBrowser()) { - hostType = HostType.BROWSER; - } else if (isWebWorker()) { - hostType = HostType.WEB_WORKER; - } - return hostType; -} - -function isDevEnvironment(): boolean { - const envType = getEnvironmentVariable(NODEJS_ENV_VAR); - if (NODEJS_DEV_ENV_VAL === envType?.toLowerCase()) { - return true; - } - return false; -} - -export function isBrowser() { - // https://developer.mozilla.org/en-US/docs/Web/API/Window - const isWindowDefinedAsExpected = typeof window === "object" && typeof Window === "function" && window instanceof Window; - // https://developer.mozilla.org/en-US/docs/Web/API/Document - const isDocumentDefinedAsExpected = typeof document === "object" && typeof Document === "function" && document instanceof Document; - - return isWindowDefinedAsExpected && isDocumentDefinedAsExpected; -} - -export function isWebWorker() { - // https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope - const workerGlobalScopeDefined = typeof WorkerGlobalScope !== "undefined"; - // https://developer.mozilla.org/en-US/docs/Web/API/WorkerNavigator - const isNavigatorDefinedAsExpected = typeof navigator === "object" && typeof WorkerNavigator === "function" && navigator instanceof WorkerNavigator; - // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers#importing_scripts_and_libraries - const importScriptsAsGlobalFunction = typeof importScripts === "function"; - - return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected; -} - +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions } from "@azure/app-configuration"; +import { AzureAppConfigurationOptions } from "../AzureAppConfigurationOptions.js"; +import { FeatureFlagTracingOptions } from "./FeatureFlagTracingOptions.js"; +import { AIConfigurationTracingOptions } from "./AIConfigurationTracingOptions.js"; +import { + AZURE_FUNCTION_ENV_VAR, + AZURE_WEB_APP_ENV_VAR, + CONTAINER_APP_ENV_VAR, + DEV_ENV_VAL, + ENV_AZURE_APP_CONFIGURATION_TRACING_DISABLED, + ENV_KEY, + FEATURE_FILTER_TYPE_KEY, + FF_MAX_VARIANTS_KEY, + FF_FEATURES_KEY, + HOST_TYPE_KEY, + HostType, + KEY_VAULT_CONFIGURED_TAG, + KUBERNETES_ENV_VAR, + NODEJS_DEV_ENV_VAL, + NODEJS_ENV_VAR, + REQUEST_TYPE_KEY, + RequestType, + SERVICE_FABRIC_ENV_VAR, + CORRELATION_CONTEXT_HEADER_NAME, + REPLICA_COUNT_KEY, + FAILOVER_REQUEST_TAG, + FEATURES_KEY, + LOAD_BALANCE_CONFIGURED_TAG, + FM_VERSION_KEY, + DELIMITER, + AI_CONFIGURATION_TAG, + AI_CHAT_COMPLETION_CONFIGURATION_TAG +} from "./constants"; + +export interface RequestTracingOptions { + enabled: boolean; + appConfigOptions: AzureAppConfigurationOptions | undefined; + initialLoadCompleted: boolean; + replicaCount: number; + isFailoverRequest: boolean; + featureFlagTracing: FeatureFlagTracingOptions | undefined; + fmVersion: string | undefined; + aiConfigurationTracing: AIConfigurationTracingOptions | undefined; +} + +// Utils +export function listConfigurationSettingsWithTrace( + requestTracingOptions: RequestTracingOptions, + client: AppConfigurationClient, + listOptions: ListConfigurationSettingsOptions +) { + const actualListOptions = { ...listOptions }; + if (requestTracingOptions.enabled) { + actualListOptions.requestOptions = { + customHeaders: { + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) + } + }; + } + + return client.listConfigurationSettings(actualListOptions); +} + +export function getConfigurationSettingWithTrace( + requestTracingOptions: RequestTracingOptions, + client: AppConfigurationClient, + configurationSettingId: ConfigurationSettingId, + getOptions?: GetConfigurationSettingOptions, +) { + const actualGetOptions = { ...getOptions }; + + if (requestTracingOptions.enabled) { + actualGetOptions.requestOptions = { + customHeaders: { + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) + } + }; + } + + return client.getConfigurationSetting(configurationSettingId, actualGetOptions); +} + +export function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string { + /* + RequestType: 'Startup' during application starting up, 'Watch' after startup completed. + Host: identify with defined envs + Env: identify by env `NODE_ENV` which is a popular but not standard. Usually, the value can be "development", "production". + ReplicaCount: identify how many replicas are found + Features: LB + Filter: CSTM+TIME+TRGT + MaxVariants: identify the max number of variants feature flag uses + FFFeatures: Seed+Telemetry + UsersKeyVault + Failover + */ + const keyValues = new Map(); + const tags: string[] = []; + + keyValues.set(REQUEST_TYPE_KEY, requestTracingOptions.initialLoadCompleted ? RequestType.WATCH : RequestType.STARTUP); + keyValues.set(HOST_TYPE_KEY, getHostType()); + keyValues.set(ENV_KEY, isDevEnvironment() ? DEV_ENV_VAL : undefined); + + const appConfigOptions = requestTracingOptions.appConfigOptions; + if (appConfigOptions?.keyVaultOptions) { + const { credential, secretClients, secretResolver } = appConfigOptions.keyVaultOptions; + if (credential !== undefined || secretClients?.length || secretResolver !== undefined) { + tags.push(KEY_VAULT_CONFIGURED_TAG); + } + } + + const featureFlagTracing = requestTracingOptions.featureFlagTracing; + if (featureFlagTracing) { + keyValues.set(FEATURE_FILTER_TYPE_KEY, featureFlagTracing.usesAnyFeatureFilter() ? featureFlagTracing.createFeatureFiltersString() : undefined); + keyValues.set(FF_FEATURES_KEY, featureFlagTracing.usesAnyTracingFeature() ? featureFlagTracing.createFeaturesString() : undefined); + if (featureFlagTracing.maxVariants > 0) { + keyValues.set(FF_MAX_VARIANTS_KEY, featureFlagTracing.maxVariants.toString()); + } + } + + if (requestTracingOptions.isFailoverRequest) { + tags.push(FAILOVER_REQUEST_TAG); + } + if (requestTracingOptions.replicaCount > 0) { + keyValues.set(REPLICA_COUNT_KEY, requestTracingOptions.replicaCount.toString()); + } + if (requestTracingOptions.fmVersion) { + keyValues.set(FM_VERSION_KEY, requestTracingOptions.fmVersion); + } + + // Use compact tags for new tracing features: Features=LB+AI+AICC... + keyValues.set(FEATURES_KEY, usesAnyTracingFeature(requestTracingOptions) ? createFeaturesString(requestTracingOptions) : undefined); + + const contextParts: string[] = []; + for (const [key, value] of keyValues) { + if (value !== undefined) { + contextParts.push(`${key}=${value}`); + } + } + for (const tag of tags) { + contextParts.push(tag); + } + + return contextParts.join(","); +} + +export function requestTracingEnabled(): boolean { + const requestTracingDisabledEnv = getEnvironmentVariable(ENV_AZURE_APP_CONFIGURATION_TRACING_DISABLED); + const disabled = requestTracingDisabledEnv?.toLowerCase() === "true"; + return !disabled; +} + +function usesAnyTracingFeature(requestTracingOptions: RequestTracingOptions): boolean { + return (requestTracingOptions.appConfigOptions?.loadBalancingEnabled ?? false) || + (requestTracingOptions.aiConfigurationTracing?.usesAnyTracingFeature() ?? false); +} + +function createFeaturesString(requestTracingOptions: RequestTracingOptions): string { + const tags: string[] = []; + if (requestTracingOptions.appConfigOptions?.loadBalancingEnabled) { + tags.push(LOAD_BALANCE_CONFIGURED_TAG); + } + if (requestTracingOptions.aiConfigurationTracing?.usesAIConfiguration) { + tags.push(AI_CONFIGURATION_TAG); + } + if (requestTracingOptions.aiConfigurationTracing?.usesAIChatCompletionConfiguration) { + tags.push(AI_CHAT_COMPLETION_CONFIGURATION_TAG); + } + return tags.join(DELIMITER); +} + +function getEnvironmentVariable(name: string) { + // Make it compatible with non-Node.js runtime + if (typeof process !== "undefined" && typeof process?.env === "object") { + return process.env[name]; + } else { + return undefined; + } +} + +function getHostType(): string | undefined { + let hostType: string | undefined; + if (getEnvironmentVariable(AZURE_FUNCTION_ENV_VAR)) { + hostType = HostType.AZURE_FUNCTION; + } else if (getEnvironmentVariable(AZURE_WEB_APP_ENV_VAR)) { + hostType = HostType.AZURE_WEB_APP; + } else if (getEnvironmentVariable(CONTAINER_APP_ENV_VAR)) { + hostType = HostType.CONTAINER_APP; + } else if (getEnvironmentVariable(KUBERNETES_ENV_VAR)) { + hostType = HostType.KUBERNETES; + } else if (getEnvironmentVariable(SERVICE_FABRIC_ENV_VAR)) { + hostType = HostType.SERVICE_FABRIC; + } else if (isBrowser()) { + hostType = HostType.BROWSER; + } else if (isWebWorker()) { + hostType = HostType.WEB_WORKER; + } + return hostType; +} + +function isDevEnvironment(): boolean { + const envType = getEnvironmentVariable(NODEJS_ENV_VAR); + if (NODEJS_DEV_ENV_VAL === envType?.toLowerCase()) { + return true; + } + return false; +} + +export function isBrowser() { + // https://developer.mozilla.org/en-US/docs/Web/API/Window + const isWindowDefinedAsExpected = typeof window === "object" && typeof Window === "function" && window instanceof Window; + // https://developer.mozilla.org/en-US/docs/Web/API/Document + const isDocumentDefinedAsExpected = typeof document === "object" && typeof Document === "function" && document instanceof Document; + + return isWindowDefinedAsExpected && isDocumentDefinedAsExpected; +} + +export function isWebWorker() { + // https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope + const workerGlobalScopeDefined = typeof WorkerGlobalScope !== "undefined"; + // https://developer.mozilla.org/en-US/docs/Web/API/WorkerNavigator + const isNavigatorDefinedAsExpected = typeof navigator === "object" && typeof WorkerNavigator === "function" && navigator instanceof WorkerNavigator; + // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers#importing_scripts_and_libraries + const importScriptsAsGlobalFunction = typeof importScripts === "function"; + + return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected; +} + diff --git a/src/types.ts b/src/types.ts index a8181378..faa15285 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,52 +1,52 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -/** - * SettingSelector is used to select key-values from Azure App Configuration based on keys and labels. - */ -export type SettingSelector = { - /** - * The key filter to apply when querying Azure App Configuration for key-values. - * - * @remarks - * An asterisk `*` can be added to the end to return all key-values whose key begins with the key filter. - * e.g. key filter `abc*` returns all key-values whose key starts with `abc`. - * A comma `,` can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. - * Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. - * E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. - * For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\). - * e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. - */ - keyFilter: string, - - /** - * The label filter to apply when querying Azure App Configuration for key-values. - * - * @remarks - * The characters asterisk `*` and comma `,` are not supported. - * Backslash `\` character is reserved and must be escaped using another backslash `\`. - * - * @defaultValue `LabelFilter.Null`, matching key-values without a label. - */ - labelFilter?: string -}; - -/** - * KeyFilter is used to filter key-values based on keys. - */ -export enum KeyFilter { - /** - * Matches all key-values. - */ - Any = "*" -} - -/** - * LabelFilter is used to filter key-values based on labels. - */ -export enum LabelFilter { - /** - * Matches key-values without a label. - */ - Null = "\0" -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * SettingSelector is used to select key-values from Azure App Configuration based on keys and labels. + */ +export type SettingSelector = { + /** + * The key filter to apply when querying Azure App Configuration for key-values. + * + * @remarks + * An asterisk `*` can be added to the end to return all key-values whose key begins with the key filter. + * e.g. key filter `abc*` returns all key-values whose key starts with `abc`. + * A comma `,` can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. + * Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. + * E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. + * For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\). + * e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. + */ + keyFilter: string, + + /** + * The label filter to apply when querying Azure App Configuration for key-values. + * + * @remarks + * The characters asterisk `*` and comma `,` are not supported. + * Backslash `\` character is reserved and must be escaped using another backslash `\`. + * + * @defaultValue `LabelFilter.Null`, matching key-values without a label. + */ + labelFilter?: string +}; + +/** + * KeyFilter is used to filter key-values based on keys. + */ +export enum KeyFilter { + /** + * Matches all key-values. + */ + Any = "*" +} + +/** + * LabelFilter is used to filter key-values based on labels. + */ +export enum LabelFilter { + /** + * Matches key-values without a label. + */ + Null = "\0" +} diff --git a/src/version.ts b/src/version.ts index b0d0e88d..92cdac8c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export const VERSION = "2.0.2"; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const VERSION = "2.0.2"; diff --git a/test/clientOptions.test.ts b/test/clientOptions.test.ts index 3401c19a..f3e871bd 100644 --- a/test/clientOptions.test.ts +++ b/test/clientOptions.test.ts @@ -1,132 +1,132 @@ -// 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.js"; -import { MAX_TIME_OUT, createMockedConnectionString } from "./utils/testHelper.js"; -import * as nock from "nock"; - -class HttpRequestCountPolicy { - count: number; - name: string; - - constructor() { - this.count = 0; - this.name = "HttpRequestCountPolicy"; - } - sendRequest(req, next) { - this.count++; - return next(req).then(resp => { resp.status = 500; return resp; }); - } - resetCount() { - this.count = 0; - } -} - -describe("custom client options", function () { - this.timeout(MAX_TIME_OUT); - - const fakeEndpoint = "https://azure.azconfig.io"; - beforeEach(() => { - // Thus here mock it to reply 500, in which case the retry mechanism works. - nock(fakeEndpoint).persist().get(() => true).reply(500); - }); - - afterEach(() => { - nock.restore(); - }); - - it("should retry 2 times by default", async () => { - const countPolicy = new HttpRequestCountPolicy(); - const loadPromise = () => { - return load(createMockedConnectionString(fakeEndpoint), { - clientOptions: { - additionalPolicies: [{ - policy: countPolicy, - position: "perRetry" - }] - }, - startupOptions: { - timeoutInMs: 5_000 - } - }); - }; - let error; - try { - await loadPromise(); - } catch (e) { - error = e; - } - expect(error).not.undefined; - expect(countPolicy.count).eq(3); - }); - - it("should override default retry options", async () => { - const countPolicy = new HttpRequestCountPolicy(); - const loadWithMaxRetries = (maxRetries: number) => { - return load(createMockedConnectionString(fakeEndpoint), { - clientOptions: { - additionalPolicies: [{ - policy: countPolicy, - position: "perRetry" - }], - retryOptions: { - maxRetries - } - }, - startupOptions: { - timeoutInMs: 5_000 - } - }); - }; - - let error; - try { - error = undefined; - await loadWithMaxRetries(0); - } catch (e) { - error = e; - } - expect(error).not.undefined; - expect(countPolicy.count).eq(1); - - countPolicy.resetCount(); - try { - error = undefined; - await loadWithMaxRetries(1); - } catch (e) { - error = e; - } - expect(error).not.undefined; - expect(countPolicy.count).eq(2); - }); - - it("should retry on DNS failure", async () => { - nock.restore(); // stop mocking with 500 error but sending real requests which will fail with ENOTFOUND - const countPolicy = new HttpRequestCountPolicy(); - const loadPromise = () => { - return load(createMockedConnectionString(fakeEndpoint), { - clientOptions: { - additionalPolicies: [{ - policy: countPolicy, - position: "perRetry" - }] - }, - startupOptions: { - timeoutInMs: 5_000 - } - }); - }; - let error; - try { - await loadPromise(); - } catch (e) { - error = e; - } - expect(error).not.undefined; - expect(countPolicy.count).eq(3); - }); -}); +// 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.js"; +import { MAX_TIME_OUT, createMockedConnectionString } from "./utils/testHelper.js"; +import * as nock from "nock"; + +class HttpRequestCountPolicy { + count: number; + name: string; + + constructor() { + this.count = 0; + this.name = "HttpRequestCountPolicy"; + } + sendRequest(req, next) { + this.count++; + return next(req).then(resp => { resp.status = 500; return resp; }); + } + resetCount() { + this.count = 0; + } +} + +describe("custom client options", function () { + this.timeout(MAX_TIME_OUT); + + const fakeEndpoint = "https://azure.azconfig.io"; + beforeEach(() => { + // Thus here mock it to reply 500, in which case the retry mechanism works. + nock(fakeEndpoint).persist().get(() => true).reply(500); + }); + + afterEach(() => { + nock.restore(); + }); + + it("should retry 2 times by default", async () => { + const countPolicy = new HttpRequestCountPolicy(); + const loadPromise = () => { + return load(createMockedConnectionString(fakeEndpoint), { + clientOptions: { + additionalPolicies: [{ + policy: countPolicy, + position: "perRetry" + }] + }, + startupOptions: { + timeoutInMs: 5_000 + } + }); + }; + let error; + try { + await loadPromise(); + } catch (e) { + error = e; + } + expect(error).not.undefined; + expect(countPolicy.count).eq(3); + }); + + it("should override default retry options", async () => { + const countPolicy = new HttpRequestCountPolicy(); + const loadWithMaxRetries = (maxRetries: number) => { + return load(createMockedConnectionString(fakeEndpoint), { + clientOptions: { + additionalPolicies: [{ + policy: countPolicy, + position: "perRetry" + }], + retryOptions: { + maxRetries + } + }, + startupOptions: { + timeoutInMs: 5_000 + } + }); + }; + + let error; + try { + error = undefined; + await loadWithMaxRetries(0); + } catch (e) { + error = e; + } + expect(error).not.undefined; + expect(countPolicy.count).eq(1); + + countPolicy.resetCount(); + try { + error = undefined; + await loadWithMaxRetries(1); + } catch (e) { + error = e; + } + expect(error).not.undefined; + expect(countPolicy.count).eq(2); + }); + + it("should retry on DNS failure", async () => { + nock.restore(); // stop mocking with 500 error but sending real requests which will fail with ENOTFOUND + const countPolicy = new HttpRequestCountPolicy(); + const loadPromise = () => { + return load(createMockedConnectionString(fakeEndpoint), { + clientOptions: { + additionalPolicies: [{ + policy: countPolicy, + position: "perRetry" + }] + }, + startupOptions: { + timeoutInMs: 5_000 + } + }); + }; + let error; + try { + await loadPromise(); + } catch (e) { + error = e; + } + expect(error).not.undefined; + expect(countPolicy.count).eq(3); + }); +}); diff --git a/test/exportedApi.ts b/test/exportedApi.ts index c49bc1a3..8a0e4757 100644 --- a/test/exportedApi.ts +++ b/test/exportedApi.ts @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + export { load } from "../src"; diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 0219cdc2..605e5292 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -1,340 +1,340 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import * as chai from "chai"; -import * as chaiAsPromised from "chai-as-promised"; -import { featureFlagContentType } from "@azure/app-configuration"; -import { load } from "./exportedApi.js"; -import { MAX_TIME_OUT, createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js"; -chai.use(chaiAsPromised); -const expect = chai.expect; - -const sampleVariantValue = JSON.stringify({ - "id": "variant", - "description": "", - "enabled": true, - "variants": [ - { - "name": "Off", - "configuration_value": false - }, - { - "name": "On", - "configuration_value": true - } - ], - "allocation": { - "percentile": [ - { - "variant": "Off", - "from": 0, - "to": 40 - }, - { - "variant": "On", - "from": 49, - "to": 100 - } - ], - "default_when_enabled": "Off", - "default_when_disabled": "Off" - }, - "telemetry": { - "enabled": false - } -}); - -const mockedKVs = [{ - key: "app.settings.fontColor", - value: "red", -}, { - key: ".appconfig.featureflag/variant", - value: sampleVariantValue, - contentType: featureFlagContentType, -}].map(createMockedKeyValue).concat([ - createMockedFeatureFlag("FlagWithTestLabel", { enabled: true }, {label: "Test"}), - createMockedFeatureFlag("Alpha_1", { enabled: true }), - createMockedFeatureFlag("Alpha_2", { enabled: false }), - createMockedFeatureFlag("Telemetry_1", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag"}), - createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag", label: "Test"}), - createMockedFeatureFlag("NoPercentileAndSeed", { - enabled: true, - telemetry: { enabled: true }, - variants: [ { name: "Control" }, { name: "Test" } ], - allocation: { - default_when_disabled: "Control", - user: [ {users: ["Jeff"], variant: "Test"} ] - } - }), - createMockedFeatureFlag("SeedOnly", { - enabled: true, - telemetry: { enabled: true }, - variants: [ { name: "Control" }, { name: "Test" } ], - allocation: { - default_when_disabled: "Control", - user: [ {users: ["Jeff"], variant: "Test"} ], - seed: "123" - } - }), - createMockedFeatureFlag("DefaultWhenEnabledOnly", { - enabled: true, - telemetry: { enabled: true }, - variants: [ { name: "Control" }, { name: "Test" } ], - allocation: { - default_when_enabled: "Control" - } - }), - createMockedFeatureFlag("PercentileOnly", { - enabled: true, - telemetry: { enabled: true }, - variants: [ ], - allocation: { - percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ] - } - }), - createMockedFeatureFlag("SimpleConfigurationValue", { - enabled: true, - telemetry: { enabled: true }, - variants: [ { name: "Control", configuration_value: "standard" }, { name: "Test", configuration_value: "special" } ], - allocation: { - default_when_enabled: "Control", - percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ], - seed: "123" - } - }), - createMockedFeatureFlag("ComplexConfigurationValue", { - enabled: true, - telemetry: { enabled: true }, - variants: [ { name: "Control", configuration_value: { title: { size: 100, color: "red" }, options: [ 1, 2, 3 ]} }, { name: "Test", configuration_value: { title: { size: 200, color: "blue" }, options: [ "1", "2", "3" ]} } ], - allocation: { - default_when_enabled: "Control", - percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ], - seed: "123" - } - }), - createMockedFeatureFlag("TelemetryVariantPercentile", { - enabled: true, - telemetry: { enabled: true }, - variants: [ - { - name: "True_Override", - configuration_value: { - someOtherKey: { - someSubKey: "someSubValue" - }, - someKey4: [3, 1, 4, true], - someKey: "someValue", - someKey3: 3.14, - someKey2: 3 - } - } - ], - allocation: { - default_when_enabled: "True_Override", - percentile: [ - { - variant: "True_Override", - from: 0, - to: 100 - } - ] - } - }), - createMockedFeatureFlag("Complete", { - enabled: true, - telemetry: { enabled: true }, - variants: [ - { - name: "Large", - configuration_value: 100 - }, - { - name: "Medium", - configuration_value: 50 - }, - { - name: "Small", - configuration_value: 10 - } - ], - allocation: { - percentile: [ - { - variant: "Large", - from: 0, - to: 25 - }, - { - variant: "Medium", - from: 25, - to: 55 - }, - { - variant: "Small", - from: 55, - to: 95 - }, - { - variant: "Large", - from: 95, - to: 100 - } - ], - group: [ - { - variant: "Large", - groups: ["beta"] - } - ], - user: [ - { - variant: "Small", - users: ["Richel"] - } - ], - seed: "test-seed", - default_when_enabled: "Medium", - default_when_disabled: "Medium" - } - }) -]); - -describe("feature flags", function () { - this.timeout(MAX_TIME_OUT); - - before(() => { - mockAppConfigurationClientListConfigurationSettings([mockedKVs]); - }); - - after(() => { - restoreMocks(); - }); - - it("should load feature flags if enabled", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - featureFlagOptions: { - enabled: true - } - }); - expect(settings).not.undefined; - expect(settings.get("feature_management")).not.undefined; - expect(settings.get("feature_management").feature_flags).not.undefined; - // it should only load feature flags with no label by default - expect((settings.get("feature_management").feature_flags as any[]).find(ff => ff.id === "FlagWithTestLabel")).to.be.undefined; - - const settings2 = await load(connectionString, { - featureFlagOptions: { - enabled: true, - selectors: [ { keyFilter: "*", labelFilter: "Test" } ] - } - }); - expect((settings2.get("feature_management").feature_flags as any[]).find(ff => ff.id === "FlagWithTestLabel")).not.undefined; - }); - - it("should not load feature flags if disabled", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - featureFlagOptions: { - enabled: false - } - }); - expect(settings).not.undefined; - expect(settings.get("feature_management")).undefined; - }); - - it("should not load feature flags if featureFlagOptions not specified", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString); - expect(settings).not.undefined; - expect(settings.get("feature_management")).undefined; - }); - - it("should load feature flags with custom selector", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - featureFlagOptions: { - enabled: true, - selectors: [{ - keyFilter: "Alpha*" - }] - } - }); - expect(settings).not.undefined; - expect(settings.get("feature_management")).not.undefined; - const featureFlags = settings.get("feature_management").feature_flags; - expect(featureFlags).not.undefined; - expect((featureFlags as []).length).equals(2); - }); - - it("should parse variant", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - featureFlagOptions: { - enabled: true, - selectors: [{ - keyFilter: "variant" - }] - } - }); - expect(settings).not.undefined; - expect(settings.get("feature_management")).not.undefined; - const featureFlags = settings.get("feature_management").feature_flags; - expect(featureFlags).not.undefined; - expect((featureFlags as []).length).equals(1); - const variant = featureFlags[0]; - expect(variant).not.undefined; - expect(variant.id).equals("variant"); - expect(variant.variants).not.undefined; - expect(variant.variants.length).equals(2); - expect(variant.variants[0].configuration_value).equals(false); - expect(variant.variants[1].configuration_value).equals(true); - expect(variant.allocation).not.undefined; - expect(variant.allocation.percentile).not.undefined; - expect(variant.allocation.percentile.length).equals(2); - expect(variant.allocation.percentile[0].variant).equals("Off"); - expect(variant.allocation.percentile[1].variant).equals("On"); - expect(variant.allocation.default_when_enabled).equals("Off"); - expect(variant.allocation.default_when_disabled).equals("Off"); - expect(variant.telemetry).not.undefined; - }); - - it("should populate telemetry metadata", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - featureFlagOptions: { - enabled: true, - selectors: [ - { - keyFilter: "Telemetry_1" - }, - { - keyFilter: "Telemetry_2", - labelFilter: "Test" - } - ] - } - }); - expect(settings).not.undefined; - expect(settings.get("feature_management")).not.undefined; - const featureFlags = settings.get("feature_management").feature_flags; - expect(featureFlags).not.undefined; - expect((featureFlags as []).length).equals(2); - - let featureFlag = featureFlags[0]; - expect(featureFlag).not.undefined; - expect(featureFlag.id).equals("Telemetry_1"); - expect(featureFlag.telemetry).not.undefined; - expect(featureFlag.telemetry.enabled).equals(true); - expect(featureFlag.telemetry.metadata.ETag).equals("ETag"); - expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_1`); - - featureFlag = featureFlags[1]; - expect(featureFlag).not.undefined; - expect(featureFlag.id).equals("Telemetry_2"); - expect(featureFlag.telemetry).not.undefined; - expect(featureFlag.telemetry.enabled).equals(true); - expect(featureFlag.telemetry.metadata.ETag).equals("ETag"); - expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); - }); -}); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +import { featureFlagContentType } from "@azure/app-configuration"; +import { load } from "./exportedApi.js"; +import { MAX_TIME_OUT, createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +const sampleVariantValue = JSON.stringify({ + "id": "variant", + "description": "", + "enabled": true, + "variants": [ + { + "name": "Off", + "configuration_value": false + }, + { + "name": "On", + "configuration_value": true + } + ], + "allocation": { + "percentile": [ + { + "variant": "Off", + "from": 0, + "to": 40 + }, + { + "variant": "On", + "from": 49, + "to": 100 + } + ], + "default_when_enabled": "Off", + "default_when_disabled": "Off" + }, + "telemetry": { + "enabled": false + } +}); + +const mockedKVs = [{ + key: "app.settings.fontColor", + value: "red", +}, { + key: ".appconfig.featureflag/variant", + value: sampleVariantValue, + contentType: featureFlagContentType, +}].map(createMockedKeyValue).concat([ + createMockedFeatureFlag("FlagWithTestLabel", { enabled: true }, {label: "Test"}), + createMockedFeatureFlag("Alpha_1", { enabled: true }), + createMockedFeatureFlag("Alpha_2", { enabled: false }), + createMockedFeatureFlag("Telemetry_1", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag"}), + createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag", label: "Test"}), + createMockedFeatureFlag("NoPercentileAndSeed", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control" }, { name: "Test" } ], + allocation: { + default_when_disabled: "Control", + user: [ {users: ["Jeff"], variant: "Test"} ] + } + }), + createMockedFeatureFlag("SeedOnly", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control" }, { name: "Test" } ], + allocation: { + default_when_disabled: "Control", + user: [ {users: ["Jeff"], variant: "Test"} ], + seed: "123" + } + }), + createMockedFeatureFlag("DefaultWhenEnabledOnly", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control" }, { name: "Test" } ], + allocation: { + default_when_enabled: "Control" + } + }), + createMockedFeatureFlag("PercentileOnly", { + enabled: true, + telemetry: { enabled: true }, + variants: [ ], + allocation: { + percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ] + } + }), + createMockedFeatureFlag("SimpleConfigurationValue", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control", configuration_value: "standard" }, { name: "Test", configuration_value: "special" } ], + allocation: { + default_when_enabled: "Control", + percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ], + seed: "123" + } + }), + createMockedFeatureFlag("ComplexConfigurationValue", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control", configuration_value: { title: { size: 100, color: "red" }, options: [ 1, 2, 3 ]} }, { name: "Test", configuration_value: { title: { size: 200, color: "blue" }, options: [ "1", "2", "3" ]} } ], + allocation: { + default_when_enabled: "Control", + percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ], + seed: "123" + } + }), + createMockedFeatureFlag("TelemetryVariantPercentile", { + enabled: true, + telemetry: { enabled: true }, + variants: [ + { + name: "True_Override", + configuration_value: { + someOtherKey: { + someSubKey: "someSubValue" + }, + someKey4: [3, 1, 4, true], + someKey: "someValue", + someKey3: 3.14, + someKey2: 3 + } + } + ], + allocation: { + default_when_enabled: "True_Override", + percentile: [ + { + variant: "True_Override", + from: 0, + to: 100 + } + ] + } + }), + createMockedFeatureFlag("Complete", { + enabled: true, + telemetry: { enabled: true }, + variants: [ + { + name: "Large", + configuration_value: 100 + }, + { + name: "Medium", + configuration_value: 50 + }, + { + name: "Small", + configuration_value: 10 + } + ], + allocation: { + percentile: [ + { + variant: "Large", + from: 0, + to: 25 + }, + { + variant: "Medium", + from: 25, + to: 55 + }, + { + variant: "Small", + from: 55, + to: 95 + }, + { + variant: "Large", + from: 95, + to: 100 + } + ], + group: [ + { + variant: "Large", + groups: ["beta"] + } + ], + user: [ + { + variant: "Small", + users: ["Richel"] + } + ], + seed: "test-seed", + default_when_enabled: "Medium", + default_when_disabled: "Medium" + } + }) +]); + +describe("feature flags", function () { + this.timeout(MAX_TIME_OUT); + + before(() => { + mockAppConfigurationClientListConfigurationSettings([mockedKVs]); + }); + + after(() => { + restoreMocks(); + }); + + it("should load feature flags if enabled", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + expect(settings.get("feature_management").feature_flags).not.undefined; + // it should only load feature flags with no label by default + expect((settings.get("feature_management").feature_flags as any[]).find(ff => ff.id === "FlagWithTestLabel")).to.be.undefined; + + const settings2 = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ { keyFilter: "*", labelFilter: "Test" } ] + } + }); + expect((settings2.get("feature_management").feature_flags as any[]).find(ff => ff.id === "FlagWithTestLabel")).not.undefined; + }); + + it("should not load feature flags if disabled", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: false + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).undefined; + }); + + it("should not load feature flags if featureFlagOptions not specified", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + expect(settings).not.undefined; + expect(settings.get("feature_management")).undefined; + }); + + it("should load feature flags with custom selector", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "Alpha*" + }] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(2); + }); + + it("should parse variant", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "variant" + }] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + const variant = featureFlags[0]; + expect(variant).not.undefined; + expect(variant.id).equals("variant"); + expect(variant.variants).not.undefined; + expect(variant.variants.length).equals(2); + expect(variant.variants[0].configuration_value).equals(false); + expect(variant.variants[1].configuration_value).equals(true); + expect(variant.allocation).not.undefined; + expect(variant.allocation.percentile).not.undefined; + expect(variant.allocation.percentile.length).equals(2); + expect(variant.allocation.percentile[0].variant).equals("Off"); + expect(variant.allocation.percentile[1].variant).equals("On"); + expect(variant.allocation.default_when_enabled).equals("Off"); + expect(variant.allocation.default_when_disabled).equals("Off"); + expect(variant.telemetry).not.undefined; + }); + + it("should populate telemetry metadata", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ + { + keyFilter: "Telemetry_1" + }, + { + keyFilter: "Telemetry_2", + labelFilter: "Test" + } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(2); + + let featureFlag = featureFlags[0]; + expect(featureFlag).not.undefined; + expect(featureFlag.id).equals("Telemetry_1"); + expect(featureFlag.telemetry).not.undefined; + expect(featureFlag.telemetry.enabled).equals(true); + expect(featureFlag.telemetry.metadata.ETag).equals("ETag"); + expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_1`); + + featureFlag = featureFlags[1]; + expect(featureFlag).not.undefined; + expect(featureFlag.id).equals("Telemetry_2"); + expect(featureFlag.telemetry).not.undefined; + expect(featureFlag.telemetry.enabled).equals(true); + expect(featureFlag.telemetry.metadata.ETag).equals("ETag"); + expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); + }); +}); diff --git a/test/json.test.ts b/test/json.test.ts index cb937bd9..a2b57907 100644 --- a/test/json.test.ts +++ b/test/json.test.ts @@ -1,91 +1,91 @@ -// 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.js"; -import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedKeyVaultReference, createMockedJsonKeyValue } from "./utils/testHelper.js"; - -const jsonKeyValue = createMockedJsonKeyValue("json.settings.logging", '{"Test":{"Level":"Debug"},"Prod":{"Level":"Warning"}}'); -const keyVaultKeyValue = createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"); - -describe("json", function () { - this.timeout(MAX_TIME_OUT); - - beforeEach(() => { - }); - - afterEach(() => { - restoreMocks(); - }); - - it("should load and parse if content type is application/json", async () => { - mockAppConfigurationClientListConfigurationSettings([[jsonKeyValue]]); - - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString); - expect(settings).not.undefined; - const logging = settings.get("json.settings.logging"); - expect(logging).not.undefined; - expect(logging.Test).not.undefined; - expect(logging.Test.Level).eq("Debug"); - expect(logging.Prod).not.undefined; - expect(logging.Prod.Level).eq("Warning"); - }); - - it("should not parse key-vault reference", async () => { - mockAppConfigurationClientListConfigurationSettings([[jsonKeyValue, keyVaultKeyValue]]); - - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - keyVaultOptions: { - secretResolver: (url) => `Resolved: ${url.toString()}` - } - }); - expect(settings).not.undefined; - const resolvedSecret = settings.get("TestKey"); - expect(resolvedSecret).not.undefined; - expect(resolvedSecret.uri).undefined; - expect(typeof resolvedSecret).eq("string"); - }); - - it("should parse different kinds of legal values", async () => { - mockAppConfigurationClientListConfigurationSettings([[ - /** - * A JSON value MUST be an object, array, number, or string, false, null, true - * See https://www.ietf.org/rfc/rfc4627.txt - */ - createMockedJsonKeyValue("json.settings.object", "{}"), - createMockedJsonKeyValue("json.settings.array", "[]"), - createMockedJsonKeyValue("json.settings.number", "8"), - createMockedJsonKeyValue("json.settings.string", "string"), - createMockedJsonKeyValue("json.settings.false", "false"), - createMockedJsonKeyValue("json.settings.true", "true"), - createMockedJsonKeyValue("json.settings.null", "null"), - createMockedJsonKeyValue("json.settings.literalNull", null), // possible value via Portal's advanced edit. - // Special tricky values related to JavaScript - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean#boolean_coercion - createMockedJsonKeyValue("json.settings.zero", 0), - createMockedJsonKeyValue("json.settings.emptyString", ""), // should fail JSON.parse and use string value as fallback - createMockedJsonKeyValue("json.settings.illegalString", "[unclosed"), // should fail JSON.parse - - ]]); - const connectionString = createMockedConnectionString(); - 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(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"); - expect(settings.get("json.settings.false")).eq(false, "is false"); - expect(settings.get("json.settings.true")).eq(true, "is true"); - expect(settings.get("json.settings.null")).eq(null, "is null"); - expect(settings.get("json.settings.literalNull")).eq(null, "is literal null"); - expect(settings.get("json.settings.zero")).eq(0, "is zero"); - expect(settings.get("json.settings.emptyString")).eq("", "is empty string"); - expect(settings.get("json.settings.illegalString")).eq("[unclosed", "is illegal string"); - }); -}); +// 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.js"; +import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedKeyVaultReference, createMockedJsonKeyValue } from "./utils/testHelper.js"; + +const jsonKeyValue = createMockedJsonKeyValue("json.settings.logging", '{"Test":{"Level":"Debug"},"Prod":{"Level":"Warning"}}'); +const keyVaultKeyValue = createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"); + +describe("json", function () { + this.timeout(MAX_TIME_OUT); + + beforeEach(() => { + }); + + afterEach(() => { + restoreMocks(); + }); + + it("should load and parse if content type is application/json", async () => { + mockAppConfigurationClientListConfigurationSettings([[jsonKeyValue]]); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + expect(settings).not.undefined; + const logging = settings.get("json.settings.logging"); + expect(logging).not.undefined; + expect(logging.Test).not.undefined; + expect(logging.Test.Level).eq("Debug"); + expect(logging.Prod).not.undefined; + expect(logging.Prod.Level).eq("Warning"); + }); + + it("should not parse key-vault reference", async () => { + mockAppConfigurationClientListConfigurationSettings([[jsonKeyValue, keyVaultKeyValue]]); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + keyVaultOptions: { + secretResolver: (url) => `Resolved: ${url.toString()}` + } + }); + expect(settings).not.undefined; + const resolvedSecret = settings.get("TestKey"); + expect(resolvedSecret).not.undefined; + expect(resolvedSecret.uri).undefined; + expect(typeof resolvedSecret).eq("string"); + }); + + it("should parse different kinds of legal values", async () => { + mockAppConfigurationClientListConfigurationSettings([[ + /** + * A JSON value MUST be an object, array, number, or string, false, null, true + * See https://www.ietf.org/rfc/rfc4627.txt + */ + createMockedJsonKeyValue("json.settings.object", "{}"), + createMockedJsonKeyValue("json.settings.array", "[]"), + createMockedJsonKeyValue("json.settings.number", "8"), + createMockedJsonKeyValue("json.settings.string", "string"), + createMockedJsonKeyValue("json.settings.false", "false"), + createMockedJsonKeyValue("json.settings.true", "true"), + createMockedJsonKeyValue("json.settings.null", "null"), + createMockedJsonKeyValue("json.settings.literalNull", null), // possible value via Portal's advanced edit. + // Special tricky values related to JavaScript + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean#boolean_coercion + createMockedJsonKeyValue("json.settings.zero", 0), + createMockedJsonKeyValue("json.settings.emptyString", ""), // should fail JSON.parse and use string value as fallback + createMockedJsonKeyValue("json.settings.illegalString", "[unclosed"), // should fail JSON.parse + + ]]); + const connectionString = createMockedConnectionString(); + 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(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"); + expect(settings.get("json.settings.false")).eq(false, "is false"); + expect(settings.get("json.settings.true")).eq(true, "is true"); + expect(settings.get("json.settings.null")).eq(null, "is null"); + expect(settings.get("json.settings.literalNull")).eq(null, "is literal null"); + expect(settings.get("json.settings.zero")).eq(0, "is zero"); + expect(settings.get("json.settings.emptyString")).eq("", "is empty string"); + expect(settings.get("json.settings.illegalString")).eq("[unclosed", "is illegal string"); + }); +}); diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index 219a0bda..81dc429d 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -1,130 +1,130 @@ -// 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.js"; -import { MAX_TIME_OUT, sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } from "./utils/testHelper.js"; -import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; - -const mockedData = [ - // key, secretUri, value - ["TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName", "SecretValue"], - ["TestKeyFixedVersion", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName/741a0fc52610449baffd6e1c55b9d459", "OldSecretValue"], - ["TestKey2", "https://fake-vault-name2.vault.azure.net/secrets/fakeSecretName2", "SecretValue2"] -]; - -function mockAppConfigurationClient() { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const kvs = mockedData.map(([key, vaultUri, _value]) => createMockedKeyVaultReference(key, vaultUri)); - mockAppConfigurationClientListConfigurationSettings([kvs]); -} - -function mockNewlyCreatedKeyVaultSecretClients() { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - mockSecretClientGetSecret(mockedData.map(([_key, secretUri, value]) => [secretUri, value])); -} - -describe("key vault reference", function () { - this.timeout(MAX_TIME_OUT); - - beforeEach(() => { - mockAppConfigurationClient(); - mockNewlyCreatedKeyVaultSecretClients(); - }); - - afterEach(() => { - restoreMocks(); - }); - - it("require key vault options to resolve reference", async () => { - try { - await load(createMockedConnectionString()); - } catch (error) { - expect(error.message).eq("Failed to load."); - expect(error.cause.message).eq("Failed to process the Key Vault reference because Key Vault options are not configured."); - return; - } - // we should never reach here, load should throw an error - throw new Error("Expected load to throw."); - }); - - it("should resolve key vault reference with credential", async () => { - const settings = await load(createMockedConnectionString(), { - keyVaultOptions: { - credential: createMockedTokenCredential() - } - }); - expect(settings).not.undefined; - expect(settings.get("TestKey")).eq("SecretValue"); - expect(settings.get("TestKeyFixedVersion")).eq("OldSecretValue"); - }); - - it("should resolve key vault reference with secret resolver", async () => { - const settings = await load(createMockedConnectionString(), { - keyVaultOptions: { - secretResolver: (kvrUrl) => { - return "SecretResolver::" + kvrUrl.toString(); - } - } - }); - expect(settings).not.undefined; - expect(settings.get("TestKey")).eq("SecretResolver::https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"); - }); - - it("should resolve key vault reference with corresponding secret clients", async () => { - sinon.restore(); - mockAppConfigurationClient(); - - // mock specific behavior per secret client - const client1 = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); - sinon.stub(client1, "getSecret").returns(Promise.resolve({value: "SecretValueViaClient1" } as KeyVaultSecret)); - const client2 = new SecretClient("https://fake-vault-name2.vault.azure.net", createMockedTokenCredential()); - sinon.stub(client2, "getSecret").returns(Promise.resolve({value: "SecretValueViaClient2" } as KeyVaultSecret)); - const settings = await load(createMockedConnectionString(), { - keyVaultOptions: { - secretClients: [ - client1, - client2, - ] - } - }); - expect(settings).not.undefined; - expect(settings.get("TestKey")).eq("SecretValueViaClient1"); - expect(settings.get("TestKey2")).eq("SecretValueViaClient2"); - }); - - it("should throw error when secret clients not provided for all key vault references", async () => { - try { - await load(createMockedConnectionString(), { - keyVaultOptions: { - secretClients: [ - new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), - ] - } - }); - } catch (error) { - expect(error.message).eq("Failed to load."); - expect(error.cause.message).eq("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); - return; - } - // we should never reach here, load should throw an error - throw new Error("Expected load to throw."); - }); - - it("should fallback to use default credential when corresponding secret client not provided", async () => { - const settings = await load(createMockedConnectionString(), { - keyVaultOptions: { - secretClients: [ - new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), - ], - credential: createMockedTokenCredential() - } - }); - expect(settings).not.undefined; - expect(settings.get("TestKey")).eq("SecretValue"); - expect(settings.get("TestKey2")).eq("SecretValue2"); - }); -}); +// 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.js"; +import { MAX_TIME_OUT, sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } from "./utils/testHelper.js"; +import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; + +const mockedData = [ + // key, secretUri, value + ["TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName", "SecretValue"], + ["TestKeyFixedVersion", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName/741a0fc52610449baffd6e1c55b9d459", "OldSecretValue"], + ["TestKey2", "https://fake-vault-name2.vault.azure.net/secrets/fakeSecretName2", "SecretValue2"] +]; + +function mockAppConfigurationClient() { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const kvs = mockedData.map(([key, vaultUri, _value]) => createMockedKeyVaultReference(key, vaultUri)); + mockAppConfigurationClientListConfigurationSettings([kvs]); +} + +function mockNewlyCreatedKeyVaultSecretClients() { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + mockSecretClientGetSecret(mockedData.map(([_key, secretUri, value]) => [secretUri, value])); +} + +describe("key vault reference", function () { + this.timeout(MAX_TIME_OUT); + + beforeEach(() => { + mockAppConfigurationClient(); + mockNewlyCreatedKeyVaultSecretClients(); + }); + + afterEach(() => { + restoreMocks(); + }); + + it("require key vault options to resolve reference", async () => { + try { + await load(createMockedConnectionString()); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Failed to process the Key Vault reference because Key Vault options are not configured."); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); + + it("should resolve key vault reference with credential", async () => { + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + credential: createMockedTokenCredential() + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretValue"); + expect(settings.get("TestKeyFixedVersion")).eq("OldSecretValue"); + }); + + it("should resolve key vault reference with secret resolver", async () => { + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretResolver: (kvrUrl) => { + return "SecretResolver::" + kvrUrl.toString(); + } + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretResolver::https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"); + }); + + it("should resolve key vault reference with corresponding secret clients", async () => { + sinon.restore(); + mockAppConfigurationClient(); + + // mock specific behavior per secret client + const client1 = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + sinon.stub(client1, "getSecret").returns(Promise.resolve({value: "SecretValueViaClient1" } as KeyVaultSecret)); + const client2 = new SecretClient("https://fake-vault-name2.vault.azure.net", createMockedTokenCredential()); + sinon.stub(client2, "getSecret").returns(Promise.resolve({value: "SecretValueViaClient2" } as KeyVaultSecret)); + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [ + client1, + client2, + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretValueViaClient1"); + expect(settings.get("TestKey2")).eq("SecretValueViaClient2"); + }); + + it("should throw error when secret clients not provided for all key vault references", async () => { + try { + await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [ + new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), + ] + } + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); + + it("should fallback to use default credential when corresponding secret client not provided", async () => { + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [ + new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), + ], + credential: createMockedTokenCredential() + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretValue"); + expect(settings.get("TestKey2")).eq("SecretValue2"); + }); +}); diff --git a/test/load.test.ts b/test/load.test.ts index 599392a4..be6ebba7 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -1,421 +1,421 @@ -// 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.js"; -import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; - -const mockedKVs = [{ - 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: "app%settings%fontColor", - value: "red", -}, { - key: "app%settings%fontSize", - value: "40", -}, { - key: "TestKey", - label: "Test", - value: "TestValue", -}, { - key: "TestKey", - label: "Prod", - value: "TestValueForProd", -}, { - key: "KeyForNullValue", - value: null, -}, { - key: "KeyForEmptyValue", - value: "", -}, { - 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" -}, { - key: ".appconfig.featureflag/Beta", - value: JSON.stringify({ - "id": "Beta", - "description": "", - "enabled": true, - "conditions": { - "client_filters": [] - } - }), - contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" -} -].map(createMockedKeyValue); - -describe("load", function () { - this.timeout(MAX_TIME_OUT); - - before(() => { - mockAppConfigurationClientListConfigurationSettings([mockedKVs]); - }); - - after(() => { - restoreMocks(); - }); - - it("should load data from config store with connection string", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString); - expect(settings).not.undefined; - expect(settings.get("app.settings.fontColor")).eq("red"); - expect(settings.get("app.settings.fontSize")).eq("40"); - }); - - it("should load data from config store with aad + endpoint URL", async () => { - const endpoint = createMockedEndpoint(); - const credential = createMockedTokenCredential(); - const settings = await load(new URL(endpoint), credential); - expect(settings).not.undefined; - expect(settings.get("app.settings.fontColor")).eq("red"); - expect(settings.get("app.settings.fontSize")).eq("40"); - }); - - it("should load data from config store with aad + endpoint string", async () => { - const endpoint = createMockedEndpoint(); - const credential = createMockedTokenCredential(); - const settings = await load(endpoint, credential); - expect(settings).not.undefined; - expect(settings.get("app.settings.fontColor")).eq("red"); - expect(settings.get("app.settings.fontSize")).eq("40"); - }); - - it("should throw error given invalid connection string", async () => { - return expect(load("invalid-connection-string")).eventually.rejectedWith("Invalid connection string"); - }); - - it("should throw error given invalid endpoint URL", async () => { - const credential = createMockedTokenCredential(); - return expect(load("invalid-endpoint-url", credential)).eventually.rejectedWith("Invalid URL"); - }); - - it("should not include feature flags directly in the settings", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString); - expect(settings).not.undefined; - expect(settings.get(".appconfig.featureflag/Beta")).undefined; - }); - - 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, { - selectors: [{ - keyFilter: "app.settings.*", - labelFilter: "\0" - }], - trimKeyPrefixes: ["app.settings."] - }); - expect(settings).not.undefined; - expect(settings.get("fontColor")).eq("red"); - expect(settings.get("fontSize")).eq("40"); - }); - - it("should trim longest key prefix first", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - selectors: [{ - keyFilter: "app.*", - labelFilter: "\0" - }], - trimKeyPrefixes: ["app.", "app.settings.", "Test"] - }); - expect(settings).not.undefined; - expect(settings.get("fontColor")).eq("red"); - expect(settings.get("fontSize")).eq("40"); - }); - - it("should support null/empty value", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString); - expect(settings).not.undefined; - expect(settings.get("KeyForNullValue")).eq(null); - expect(settings.get("KeyForEmptyValue")).eq(""); - }); - - it("should not support * in label filters", async () => { - const connectionString = createMockedConnectionString(); - const loadWithWildcardLabelFilter = load(connectionString, { - selectors: [{ - keyFilter: "app.*", - labelFilter: "*" - }] - }); - 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" - }] - }); - 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 () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - selectors: [{ - keyFilter: "Test*", - labelFilter: "Test" - }, { - keyFilter: "Test*", - labelFilter: "Prod" - }] - }); - expect(settings).not.undefined; - expect(settings.get("TestKey")).eq("TestValueForProd"); - }); - - it("should deduplicate exact same selectors but keeping the precedence", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - selectors: [{ - keyFilter: "Test*", - labelFilter: "Prod" - }, { - keyFilter: "Test*", - labelFilter: "Test" - }, { - keyFilter: "Test*", - labelFilter: "Prod" - }] - }); - expect(settings).not.undefined; - 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 guaranteed 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 constructConfigurationObject() 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 2: 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: '.', ',', ';', '-', '_', '__', '/', ':'."); - }); -}); +// 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.js"; +import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; + +const mockedKVs = [{ + 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: "app%settings%fontColor", + value: "red", +}, { + key: "app%settings%fontSize", + value: "40", +}, { + key: "TestKey", + label: "Test", + value: "TestValue", +}, { + key: "TestKey", + label: "Prod", + value: "TestValueForProd", +}, { + key: "KeyForNullValue", + value: null, +}, { + key: "KeyForEmptyValue", + value: "", +}, { + 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" +}, { + key: ".appconfig.featureflag/Beta", + value: JSON.stringify({ + "id": "Beta", + "description": "", + "enabled": true, + "conditions": { + "client_filters": [] + } + }), + contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" +} +].map(createMockedKeyValue); + +describe("load", function () { + this.timeout(MAX_TIME_OUT); + + before(() => { + mockAppConfigurationClientListConfigurationSettings([mockedKVs]); + }); + + after(() => { + restoreMocks(); + }); + + it("should load data from config store with connection string", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + }); + + it("should load data from config store with aad + endpoint URL", async () => { + const endpoint = createMockedEndpoint(); + const credential = createMockedTokenCredential(); + const settings = await load(new URL(endpoint), credential); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + }); + + it("should load data from config store with aad + endpoint string", async () => { + const endpoint = createMockedEndpoint(); + const credential = createMockedTokenCredential(); + const settings = await load(endpoint, credential); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + }); + + it("should throw error given invalid connection string", async () => { + return expect(load("invalid-connection-string")).eventually.rejectedWith("Invalid connection string"); + }); + + it("should throw error given invalid endpoint URL", async () => { + const credential = createMockedTokenCredential(); + return expect(load("invalid-endpoint-url", credential)).eventually.rejectedWith("Invalid URL"); + }); + + it("should not include feature flags directly in the settings", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + expect(settings).not.undefined; + expect(settings.get(".appconfig.featureflag/Beta")).undefined; + }); + + 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, { + selectors: [{ + keyFilter: "app.settings.*", + labelFilter: "\0" + }], + trimKeyPrefixes: ["app.settings."] + }); + expect(settings).not.undefined; + expect(settings.get("fontColor")).eq("red"); + expect(settings.get("fontSize")).eq("40"); + }); + + it("should trim longest key prefix first", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app.*", + labelFilter: "\0" + }], + trimKeyPrefixes: ["app.", "app.settings.", "Test"] + }); + expect(settings).not.undefined; + expect(settings.get("fontColor")).eq("red"); + expect(settings.get("fontSize")).eq("40"); + }); + + it("should support null/empty value", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + expect(settings).not.undefined; + expect(settings.get("KeyForNullValue")).eq(null); + expect(settings.get("KeyForEmptyValue")).eq(""); + }); + + it("should not support * in label filters", async () => { + const connectionString = createMockedConnectionString(); + const loadWithWildcardLabelFilter = load(connectionString, { + selectors: [{ + keyFilter: "app.*", + labelFilter: "*" + }] + }); + 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" + }] + }); + 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 () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "Test*", + labelFilter: "Test" + }, { + keyFilter: "Test*", + labelFilter: "Prod" + }] + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("TestValueForProd"); + }); + + it("should deduplicate exact same selectors but keeping the precedence", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "Test*", + labelFilter: "Prod" + }, { + keyFilter: "Test*", + labelFilter: "Test" + }, { + keyFilter: "Test*", + labelFilter: "Prod" + }] + }); + expect(settings).not.undefined; + 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 guaranteed 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 constructConfigurationObject() 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 2: Hierarchical key-value pairs with overlapped key prefix.", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app5.settings*" + }] + }); + expect(settings).not.undefined; + expect(() => { + settings.constructConfigurationObject(); + }).to.throw("Ambiguity occurs when constructing configuration object from key 'app5.settings', value 'placeholder'. The key should not be part of another key."); + }); + + it("should construct configuration object with array", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app4.*" + }] + }); + expect(settings).not.undefined; + const data = settings.constructConfigurationObject(); + expect(data).not.undefined; + // Both { '0': 'node_modules', '1': 'dist' } and ['node_modules', 'dist'] are valid. + expect(data.app4.excludedFolders[0]).eq("node_modules"); + expect(data.app4.excludedFolders[1]).eq("dist"); + }); + + it("should construct configuration object with customized separator", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app/settings/*" + }] + }); + expect(settings).not.undefined; + const data = settings.constructConfigurationObject({ separator: "/" }); + expect(data).not.undefined; + expect(data.app.settings.fontColor).eq("red"); + expect(data.app.settings.fontSize).eq("40"); + }); + + it("should throw error when construct configuration object with invalid separator", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "app%settings%*" + }] + }); + expect(settings).not.undefined; + + expect(() => { + // Below line will throw error because of type checking, i.e. Type '"%"' is not assignable to type '"/" | "." | "," | ";" | "-" | "_" | "__" | ":" | undefined'.ts(2322) + // Here force to turn if off for testing purpose, as JavaScript does not have type checking. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + settings.constructConfigurationObject({ separator: "%" }); + }).to.throw("Invalid separator '%'. Supported values: '.', ',', ';', '-', '_', '__', '/', ':'."); + }); +}); diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 6457cb1a..d03d9436 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -1,552 +1,552 @@ -// 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.js"; -import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, restoreMocks, createMockedConnectionString, createMockedKeyValue, sleepInMs, createMockedFeatureFlag } from "./utils/testHelper.js"; -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 })); -} - -let listKvRequestCount = 0; -const listKvCallback = () => { - listKvRequestCount++; -}; -let getKvRequestCount = 0; -const getKvCallback = () => { - getKvRequestCount++; -}; - -describe("dynamic refresh", function () { - this.timeout(MAX_TIME_OUT); - - 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], listKvCallback); - mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback); - }); - - afterEach(() => { - restoreMocks(); - listKvRequestCount = 0; - getKvRequestCount = 0; - }); - - 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 for key-values or feature flags."); - }); - - 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 for key-values or feature flags."); - }); - - it("should only update values after refreshInterval", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - refreshOptions: { - enabled: true, - refreshIntervalInMs: 2000, - watchedSettings: [ - { key: "app.settings.fontColor" } - ] - } - }); - expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); - 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"); - expect(listKvRequestCount).eq(1); // no more request should be sent during the refresh interval - expect(getKvRequestCount).eq(0); // no more request should be sent during the refresh interval - - // after refreshInterval, should really refresh - await sleepInMs(2 * 1000 + 1); - await settings.refresh(); - expect(listKvRequestCount).eq(2); - expect(getKvRequestCount).eq(1); - 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(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); - 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], listKvCallback); - mockAppConfigurationClientGetConfigurationSetting(newMockedKVs, getKvCallback); - - await sleepInMs(2 * 1000 + 1); - await settings.refresh(); - expect(listKvRequestCount).eq(2); - expect(getKvRequestCount).eq(2); // one conditional request to detect change and one request as part of loading all kvs (because app.settings.fontColor doesn't exist in the response of listKv request) - 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(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); - 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(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(1); - 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(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); - 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(listKvRequestCount).eq(2); - expect(getKvRequestCount).eq(2); // two getKv request for two watched settings - 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(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(1); // app.settings.bgColor doesn't exist in the response of listKv request, so an additional getKv request is made to get it. - 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(); - expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(2); - // should not refresh - expect(settings.get("app.settings.fontColor")).eq("red"); - }); - - it("should refresh key value based on page eTag, if no watched setting is specified", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - refreshOptions: { - enabled: true, - refreshIntervalInMs: 2000 - } - }); - expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); - 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"); - - // after refreshInterval, should really refresh - await sleepInMs(2 * 1000 + 1); - await settings.refresh(); - expect(listKvRequestCount).eq(3); // 1 + 2 more requests: one conditional request to detect change and one request to reload all key values - expect(getKvRequestCount).eq(0); - expect(settings.get("app.settings.fontColor")).eq("blue"); - }); - - it("should refresh key value based on page Etag, only on change", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - refreshOptions: { - enabled: true, - refreshIntervalInMs: 2000 - } - }); - expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); - - let refreshSuccessfulCount = 0; - settings.onRefresh(() => { - refreshSuccessfulCount++; - }); - - expect(settings).not.undefined; - expect(settings.get("app.settings.fontColor")).eq("red"); - - await sleepInMs(2 * 1000 + 1); - await settings.refresh(); - expect(listKvRequestCount).eq(2); // one more conditional request to detect change - expect(getKvRequestCount).eq(0); - expect(refreshSuccessfulCount).eq(0); // no change in key values, because page etags are the same. - - // change key value - restoreMocks(); - const changedKVs = [ - { value: "blue", key: "app.settings.fontColor" }, - { value: "40", key: "app.settings.fontSize" } - ].map(createMockedKeyValue); - mockAppConfigurationClientListConfigurationSettings([changedKVs], listKvCallback); - mockAppConfigurationClientGetConfigurationSetting(changedKVs, getKvCallback); - - await sleepInMs(2 * 1000 + 1); - await settings.refresh(); - expect(listKvRequestCount).eq(4); // 2 + 2 more requests: one conditional request to detect change and one request to reload all key values - expect(getKvRequestCount).eq(0); - expect(refreshSuccessfulCount).eq(1); // change in key values, because page etags are different. - expect(settings.get("app.settings.fontColor")).eq("blue"); - }); - - it("should not refresh any more when there is refresh in progress", async () => { - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - refreshOptions: { - enabled: true, - refreshIntervalInMs: 2000, - watchedSettings: [ - { key: "app.settings.fontColor" } - ] - } - }); - expect(listKvRequestCount).eq(1); - expect(getKvRequestCount).eq(0); - expect(settings).not.undefined; - expect(settings.get("app.settings.fontColor")).eq("red"); - - // change setting - updateSetting("app.settings.fontColor", "blue"); - - // after refreshInterval, should really refresh - await sleepInMs(2 * 1000 + 1); - for (let i = 0; i < 5; i++) { // in practice, refresh should not be used in this way - settings.refresh(); // refresh "concurrently" - } - expect(listKvRequestCount).to.be.at.most(2); - expect(getKvRequestCount).to.be.at.most(1); - - await sleepInMs(1000); // wait for all 5 refresh attempts to finish - - expect(listKvRequestCount).eq(2); - expect(getKvRequestCount).eq(1); - expect(settings.get("app.settings.fontColor")).eq("blue"); - }); -}); - -describe("dynamic refresh feature flags", function () { - this.timeout(10000); - - beforeEach(() => { - }); - - afterEach(() => { - restoreMocks(); - listKvRequestCount = 0; - getKvRequestCount = 0; - }); - - it("should refresh feature flags when enabled", async () => { - mockedKVs = [ - createMockedFeatureFlag("Beta", { enabled: true }) - ]; - mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvCallback); - mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback); - - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - featureFlagOptions: { - enabled: true, - selectors: [{ - keyFilter: "Beta" - }], - refresh: { - enabled: true, - refreshIntervalInMs: 2000 // 2 seconds for quick test. - } - } - }); - expect(listKvRequestCount).eq(2); // one listKv request for kvs and one listKv request for feature flags - expect(getKvRequestCount).eq(0); - expect(settings).not.undefined; - expect(settings.get("feature_management")).not.undefined; - expect(settings.get("feature_management").feature_flags).not.undefined; - expect(settings.get("feature_management").feature_flags[0].id).eq("Beta"); - expect(settings.get("feature_management").feature_flags[0].enabled).eq(true); - - // change feature flag Beta to false - updateSetting(".appconfig.featureflag/Beta", JSON.stringify({ - "id": "Beta", - "description": "", - "enabled": false, - "conditions": { - "client_filters": [] - } - })); - - await sleepInMs(2 * 1000 + 1); - await settings.refresh(); - expect(listKvRequestCount).eq(4); // 2 + 2 more requests: one conditional request to detect change and one request to reload all feature flags - expect(getKvRequestCount).eq(0); - - expect(settings.get("feature_management").feature_flags[0].id).eq("Beta"); - expect(settings.get("feature_management").feature_flags[0].enabled).eq(false); - - }); - - it("should refresh feature flags based on page etags, only on change", async () => { - // mock multiple pages of feature flags - const page1 = [ - createMockedFeatureFlag("Alpha_1", { enabled: true }), - createMockedFeatureFlag("Alpha_2", { enabled: true }), - ]; - const page2 = [ - createMockedFeatureFlag("Beta_1", { enabled: true }), - createMockedFeatureFlag("Beta_2", { enabled: true }), - ]; - mockAppConfigurationClientListConfigurationSettings([page1, page2], listKvCallback); - mockAppConfigurationClientGetConfigurationSetting([...page1, ...page2], getKvCallback); - - const connectionString = createMockedConnectionString(); - const settings = await load(connectionString, { - featureFlagOptions: { - enabled: true, - selectors: [{ - keyFilter: "*" - }], - refresh: { - enabled: true, - refreshIntervalInMs: 2000 // 2 seconds for quick test. - } - } - }); - expect(listKvRequestCount).eq(2); - expect(getKvRequestCount).eq(0); - - let refreshSuccessfulCount = 0; - settings.onRefresh(() => { - refreshSuccessfulCount++; - }); - - await sleepInMs(2 * 1000 + 1); - await settings.refresh(); - expect(listKvRequestCount).eq(3); // one conditional request to detect change - expect(getKvRequestCount).eq(0); - expect(refreshSuccessfulCount).eq(0); // no change in feature flags, because page etags are the same. - - // change feature flag Beta_1 to false - page2[0] = createMockedFeatureFlag("Beta_1", { enabled: false }); - restoreMocks(); - mockAppConfigurationClientListConfigurationSettings([page1, page2], listKvCallback); - mockAppConfigurationClientGetConfigurationSetting([...page1, ...page2], getKvCallback); - - await sleepInMs(2 * 1000 + 1); - await settings.refresh(); - expect(listKvRequestCount).eq(5); // 3 + 2 more requests: one conditional request to detect change and one request to reload all feature flags - expect(getKvRequestCount).eq(0); - expect(refreshSuccessfulCount).eq(1); // change in feature flags, because page etags are different. - }); -}); +// 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.js"; +import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, restoreMocks, createMockedConnectionString, createMockedKeyValue, sleepInMs, createMockedFeatureFlag } from "./utils/testHelper.js"; +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 })); +} + +let listKvRequestCount = 0; +const listKvCallback = () => { + listKvRequestCount++; +}; +let getKvRequestCount = 0; +const getKvCallback = () => { + getKvRequestCount++; +}; + +describe("dynamic refresh", function () { + this.timeout(MAX_TIME_OUT); + + 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], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback); + }); + + afterEach(() => { + restoreMocks(); + listKvRequestCount = 0; + getKvRequestCount = 0; + }); + + 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 for key-values or feature flags."); + }); + + 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 for key-values or feature flags."); + }); + + it("should only update values after refreshInterval", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" } + ] + } + }); + expect(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + 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"); + expect(listKvRequestCount).eq(1); // no more request should be sent during the refresh interval + expect(getKvRequestCount).eq(0); // no more request should be sent during the refresh interval + + // after refreshInterval, should really refresh + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(2); + expect(getKvRequestCount).eq(1); + 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(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + 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], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting(newMockedKVs, getKvCallback); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(2); + expect(getKvRequestCount).eq(2); // one conditional request to detect change and one request as part of loading all kvs (because app.settings.fontColor doesn't exist in the response of listKv request) + 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(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + 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(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(1); + 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(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + 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(listKvRequestCount).eq(2); + expect(getKvRequestCount).eq(2); // two getKv request for two watched settings + 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(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(1); // app.settings.bgColor doesn't exist in the response of listKv request, so an additional getKv request is made to get it. + 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(); + expect(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(2); + // should not refresh + expect(settings.get("app.settings.fontColor")).eq("red"); + }); + + it("should refresh key value based on page eTag, if no watched setting is specified", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + expect(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + 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"); + + // after refreshInterval, should really refresh + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(3); // 1 + 2 more requests: one conditional request to detect change and one request to reload all key values + expect(getKvRequestCount).eq(0); + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); + + it("should refresh key value based on page Etag, only on change", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + expect(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + + let refreshSuccessfulCount = 0; + settings.onRefresh(() => { + refreshSuccessfulCount++; + }); + + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(2); // one more conditional request to detect change + expect(getKvRequestCount).eq(0); + expect(refreshSuccessfulCount).eq(0); // no change in key values, because page etags are the same. + + // change key value + restoreMocks(); + const changedKVs = [ + { value: "blue", key: "app.settings.fontColor" }, + { value: "40", key: "app.settings.fontSize" } + ].map(createMockedKeyValue); + mockAppConfigurationClientListConfigurationSettings([changedKVs], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting(changedKVs, getKvCallback); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(4); // 2 + 2 more requests: one conditional request to detect change and one request to reload all key values + expect(getKvRequestCount).eq(0); + expect(refreshSuccessfulCount).eq(1); // change in key values, because page etags are different. + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); + + it("should not refresh any more when there is refresh in progress", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000, + watchedSettings: [ + { key: "app.settings.fontColor" } + ] + } + }); + expect(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + + // change setting + updateSetting("app.settings.fontColor", "blue"); + + // after refreshInterval, should really refresh + await sleepInMs(2 * 1000 + 1); + for (let i = 0; i < 5; i++) { // in practice, refresh should not be used in this way + settings.refresh(); // refresh "concurrently" + } + expect(listKvRequestCount).to.be.at.most(2); + expect(getKvRequestCount).to.be.at.most(1); + + await sleepInMs(1000); // wait for all 5 refresh attempts to finish + + expect(listKvRequestCount).eq(2); + expect(getKvRequestCount).eq(1); + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); +}); + +describe("dynamic refresh feature flags", function () { + this.timeout(10000); + + beforeEach(() => { + }); + + afterEach(() => { + restoreMocks(); + listKvRequestCount = 0; + getKvRequestCount = 0; + }); + + it("should refresh feature flags when enabled", async () => { + mockedKVs = [ + createMockedFeatureFlag("Beta", { enabled: true }) + ]; + mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "Beta" + }], + refresh: { + enabled: true, + refreshIntervalInMs: 2000 // 2 seconds for quick test. + } + } + }); + expect(listKvRequestCount).eq(2); // one listKv request for kvs and one listKv request for feature flags + expect(getKvRequestCount).eq(0); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + expect(settings.get("feature_management").feature_flags).not.undefined; + expect(settings.get("feature_management").feature_flags[0].id).eq("Beta"); + expect(settings.get("feature_management").feature_flags[0].enabled).eq(true); + + // change feature flag Beta to false + updateSetting(".appconfig.featureflag/Beta", JSON.stringify({ + "id": "Beta", + "description": "", + "enabled": false, + "conditions": { + "client_filters": [] + } + })); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(4); // 2 + 2 more requests: one conditional request to detect change and one request to reload all feature flags + expect(getKvRequestCount).eq(0); + + expect(settings.get("feature_management").feature_flags[0].id).eq("Beta"); + expect(settings.get("feature_management").feature_flags[0].enabled).eq(false); + + }); + + it("should refresh feature flags based on page etags, only on change", async () => { + // mock multiple pages of feature flags + const page1 = [ + createMockedFeatureFlag("Alpha_1", { enabled: true }), + createMockedFeatureFlag("Alpha_2", { enabled: true }), + ]; + const page2 = [ + createMockedFeatureFlag("Beta_1", { enabled: true }), + createMockedFeatureFlag("Beta_2", { enabled: true }), + ]; + mockAppConfigurationClientListConfigurationSettings([page1, page2], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting([...page1, ...page2], getKvCallback); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*" + }], + refresh: { + enabled: true, + refreshIntervalInMs: 2000 // 2 seconds for quick test. + } + } + }); + expect(listKvRequestCount).eq(2); + expect(getKvRequestCount).eq(0); + + let refreshSuccessfulCount = 0; + settings.onRefresh(() => { + refreshSuccessfulCount++; + }); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(3); // one conditional request to detect change + expect(getKvRequestCount).eq(0); + expect(refreshSuccessfulCount).eq(0); // no change in feature flags, because page etags are the same. + + // change feature flag Beta_1 to false + page2[0] = createMockedFeatureFlag("Beta_1", { enabled: false }); + restoreMocks(); + mockAppConfigurationClientListConfigurationSettings([page1, page2], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting([...page1, ...page2], getKvCallback); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(5); // 3 + 2 more requests: one conditional request to detect change and one request to reload all feature flags + expect(getKvRequestCount).eq(0); + expect(refreshSuccessfulCount).eq(1); // change in feature flags, because page etags are different. + }); +}); diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 0b18f4b5..942b329b 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -1,641 +1,641 @@ -// 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 { MAX_TIME_OUT, HttpRequestHeadersPolicy, createMockedConnectionString, createMockedKeyValue, createMockedFeatureFlag, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sinon, sleepInMs } from "./utils/testHelper.js"; -import { ConfigurationClientManager } from "../src/ConfigurationClientManager.js"; -import { load } from "./exportedApi.js"; - -const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; - -describe("request tracing", function () { - this.timeout(MAX_TIME_OUT); - - const fakeEndpoint = "https://127.0.0.1"; // sufficient to test the request it sends out - const headerPolicy = new HttpRequestHeadersPolicy(); - const position: "perCall" | "perRetry" = "perCall"; - const clientOptions = { - retryOptions: { - maxRetries: 0 // save time - }, - additionalPolicies: [{ - policy: headerPolicy, - position - }] - }; - - before(() => { - }); - - after(() => { - }); - - it("should have correct user agent prefix", async () => { - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); - }); - - it("should have request type in correlation-context header", async () => { - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - expect(headerPolicy.headers.get("Correlation-Context")).eq("RequestType=Startup"); - }); - - it("should have key vault tag in correlation-context header", async () => { - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - keyVaultOptions: { - credential: createMockedTokenCredential() - }, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("UsesKeyVault")).eq(true); - }); - - it("should have loadbalancing tag in correlation-context header", async () => { - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - loadBalancingEnabled: true, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Features=LB")).eq(true); - }); - - it("should have replica count in correlation-context header", async () => { - const replicaCount = 2; - sinon.stub(ConfigurationClientManager.prototype, "getReplicaCount").returns(replicaCount); - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes(`ReplicaCount=${replicaCount}`)).eq(true); - sinon.restore(); - }); - - it("should detect env in correlation-context header", async () => { - process.env.NODE_ENV = "development"; - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Env=Dev")).eq(true); - delete process.env.NODE_ENV; - }); - - it("should detect host type in correlation-context header", async () => { - process.env.WEBSITE_SITE_NAME = "website-name"; - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=AzureWebApp")).eq(true); - delete process.env.WEBSITE_SITE_NAME; - }); - - it("should disable request tracing when AZURE_APP_CONFIGURATION_TRACING_DISABLED is true", async () => { - for (const indicator of ["TRUE", "true"]) { - process.env.AZURE_APP_CONFIGURATION_TRACING_DISABLED = indicator; - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).undefined; - } - - // 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: 1_000, - watchedSettings: [{ - key: "app.settings.fontColor" - }] - } - }); - await sleepInMs(1_000 + 1_000); - 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(); - }); - - it("should have filter type in correlation-context header if feature flags use feature filters", async () => { - let correlationContext: string = ""; - const listKvCallback = (listOptions) => { - correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; - }; - - mockAppConfigurationClientListConfigurationSettings([[ - createMockedFeatureFlag("Alpha_1", { conditions: { client_filters: [ { name: "Microsoft.TimeWindow" } ] } }), - createMockedFeatureFlag("Alpha_2", { conditions: { client_filters: [ { name: "Microsoft.Targeting" } ] } }), - createMockedFeatureFlag("Alpha_3", { conditions: { client_filters: [ { name: "CustomFilter" } ] } }) - ]], listKvCallback); - - const settings = await load(createMockedConnectionString(fakeEndpoint), { - featureFlagOptions: { - enabled: true, - selectors: [ {keyFilter: "*"} ], - refresh: { - enabled: true, - refreshIntervalInMs: 1_000 - } - } - }); - - expect(correlationContext).not.undefined; - expect(correlationContext?.includes("RequestType=Startup")).eq(true); - - await sleepInMs(1_000 + 1_000); - try { - await settings.refresh(); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - expect(correlationContext).not.undefined; - expect(correlationContext?.includes("RequestType=Watch")).eq(true); - expect(correlationContext?.includes("Filter=CSTM+TIME+TRGT")).eq(true); - - restoreMocks(); - }); - - it("should have max variants in correlation-context header if feature flags use variants", async () => { - let correlationContext: string = ""; - const listKvCallback = (listOptions) => { - correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; - }; - - mockAppConfigurationClientListConfigurationSettings([[ - createMockedFeatureFlag("Alpha_1", { variants: [ {name: "a"}, {name: "b"}] }), - createMockedFeatureFlag("Alpha_2", { variants: [ {name: "a"}, {name: "b"}, {name: "c"}] }), - createMockedFeatureFlag("Alpha_3", { variants: [] }) - ]], listKvCallback); - - const settings = await load(createMockedConnectionString(fakeEndpoint), { - featureFlagOptions: { - enabled: true, - selectors: [ {keyFilter: "*"} ], - refresh: { - enabled: true, - refreshIntervalInMs: 1_000 - } - } - }); - - expect(correlationContext).not.undefined; - expect(correlationContext?.includes("RequestType=Startup")).eq(true); - - await sleepInMs(1_000 + 1_000); - try { - await settings.refresh(); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - expect(correlationContext).not.undefined; - expect(correlationContext?.includes("RequestType=Watch")).eq(true); - expect(correlationContext?.includes("MaxVariants=3")).eq(true); - - restoreMocks(); - }); - - it("should have telemety tag in correlation-context header if feature flags enable telemetry", async () => { - let correlationContext: string = ""; - const listKvCallback = (listOptions) => { - correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; - }; - - mockAppConfigurationClientListConfigurationSettings([[ - createMockedFeatureFlag("Alpha_1", { telemetry: {enabled: true} }) - ]], listKvCallback); - - const settings = await load(createMockedConnectionString(fakeEndpoint), { - featureFlagOptions: { - enabled: true, - selectors: [ {keyFilter: "*"} ], - refresh: { - enabled: true, - refreshIntervalInMs: 1_000 - } - } - }); - - expect(correlationContext).not.undefined; - expect(correlationContext?.includes("RequestType=Startup")).eq(true); - - await sleepInMs(1_000 + 1_000); - try { - await settings.refresh(); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - expect(correlationContext).not.undefined; - expect(correlationContext?.includes("RequestType=Watch")).eq(true); - expect(correlationContext?.includes("FFFeatures=Telemetry")).eq(true); - - restoreMocks(); - }); - - it("should have seed tag in correlation-context header if feature flags use allocation seed", async () => { - let correlationContext: string = ""; - const listKvCallback = (listOptions) => { - correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; - }; - - mockAppConfigurationClientListConfigurationSettings([[ - createMockedFeatureFlag("Alpha_1", { telemetry: {enabled: true} }), - createMockedFeatureFlag("Alpha_2", { allocation: {seed: "123"} }) - ]], listKvCallback); - - const settings = await load(createMockedConnectionString(fakeEndpoint), { - featureFlagOptions: { - enabled: true, - selectors: [ {keyFilter: "*"} ], - refresh: { - enabled: true, - refreshIntervalInMs: 1_000 - } - } - }); - - expect(correlationContext).not.undefined; - expect(correlationContext?.includes("RequestType=Startup")).eq(true); - - await sleepInMs(1_000 + 1_000); - try { - await settings.refresh(); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - expect(correlationContext).not.undefined; - expect(correlationContext?.includes("RequestType=Watch")).eq(true); - expect(correlationContext?.includes("FFFeatures=Seed+Telemetry")).eq(true); - - restoreMocks(); - }); - - it("should have AI tag in correlation-context header if key values use AI configuration", async () => { - let correlationContext: string = ""; - const listKvCallback = (listOptions) => { - correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; - }; - - mockAppConfigurationClientListConfigurationSettings([[ - createMockedKeyValue({ contentType: "application/json; profile=\"https://azconfig.io/mime-profiles/ai/chat-completion\"" }) - ]], listKvCallback); - - const settings = await load(createMockedConnectionString(fakeEndpoint), { - refreshOptions: { - enabled: true, - refreshIntervalInMs: 1000 - } - }); - - expect(correlationContext).not.undefined; - expect(correlationContext?.includes("RequestType=Startup")).eq(true); - - await sleepInMs(1000 + 1); - try { - await settings.refresh(); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - expect(correlationContext).not.undefined; - expect(correlationContext?.includes("Features=AI+AICC")).eq(true); - - restoreMocks(); - }); - - describe("request tracing in Web Worker environment", () => { - let originalNavigator; - let originalWorkerNavigator; - let originalWorkerGlobalScope; - let originalImportScripts; - - before(() => { - // Save the original values to restore them later - originalNavigator = (global as any).navigator; - originalWorkerNavigator = (global as any).WorkerNavigator; - originalWorkerGlobalScope = (global as any).WorkerGlobalScope; - originalImportScripts = (global as any).importScripts; - }); - - afterEach(() => { - // Restore the original values after each test - // global.navigator was added in node 21, https://nodejs.org/api/globals.html#navigator_1 - // global.navigator only has a getter, so we have to use Object.defineProperty to modify it - Object.defineProperty(global, "navigator", { - value: originalNavigator, - configurable: true - }); - (global as any).WorkerNavigator = originalWorkerNavigator; - (global as any).WorkerGlobalScope = originalWorkerGlobalScope; - (global as any).importScripts = originalImportScripts; - }); - - it("should identify WebWorker environment", async () => { - (global as any).WorkerNavigator = function WorkerNavigator() { }; - Object.defineProperty(global, "navigator", { - value: new (global as any).WorkerNavigator(), - configurable: true - }); - (global as any).WorkerGlobalScope = function WorkerGlobalScope() { }; - (global as any).importScripts = function importScripts() { }; - - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=WebWorker")).eq(true); - }); - - it("is not WebWorker when WorkerNavigator is undefined", async () => { - Object.defineProperty(global, "navigator", { - value: { userAgent: "node.js" } as any, // Mock navigator - configurable: true - }); - (global as any).WorkerNavigator = undefined; - (global as any).WorkerGlobalScope = function WorkerGlobalScope() { }; - (global as any).importScripts = function importScripts() { }; - - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=WebWorker")).eq(false); - }); - - it("is not WebWorker when navigator is not an instance of WorkerNavigator", async () => { - Object.defineProperty(global, "navigator", { - value: { userAgent: "node.js" } as any, // Mock navigator but not an instance of WorkerNavigator - configurable: true - }); - (global as any).WorkerNavigator = function WorkerNavigator() { }; - (global as any).WorkerGlobalScope = function WorkerGlobalScope() { }; - (global as any).importScripts = function importScripts() { }; - - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=WebWorker")).eq(false); - }); - - it("is not WebWorker when WorkerGlobalScope is undefined", async () => { - (global as any).WorkerNavigator = function WorkerNavigator() { }; - Object.defineProperty(global, "navigator", { - value: new (global as any).WorkerNavigator(), - configurable: true - }); - (global as any).WorkerGlobalScope = undefined; - (global as any).importScripts = function importScripts() { }; - - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=WebWorker")).eq(false); - }); - - it("is not WebWorker when importScripts is undefined", async () => { - (global as any).WorkerNavigator = function WorkerNavigator() { }; - Object.defineProperty(global, "navigator", { - value: new (global as any).WorkerNavigator(), - configurable: true - }); - (global as any).WorkerGlobalScope = function WorkerGlobalScope() { }; - (global as any).importScripts = undefined; - - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=WebWorker")).eq(false); - }); - }); - - describe("request tracing in Web Browser environment", () => { - let originalWindowType; - let originalWindowObject; - let originalDocumentType; - let originalDocumentObject; - - before(() => { - // Save the original values to restore them later - originalWindowType = (global as any).Window; - originalWindowObject = (global as any).window; - originalDocumentType = (global as any).Document; - originalDocumentObject = (global as any).document; - }); - - afterEach(() => { - // Restore the original values after each test - (global as any).Window = originalWindowType; - (global as any).window = originalWindowObject; - (global as any).Document = originalDocumentType; - (global as any).document = originalDocumentObject; - }); - - it("should identify Web environment", async () => { - (global as any).Window = function Window() { }; - (global as any).window = new (global as any).Window(); - (global as any).Document = function Document() { }; - (global as any).document = new (global as any).Document(); - - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=Web")).eq(true); - }); - - it("is not Web when document is undefined", async () => { - (global as any).Window = function Window() { }; - (global as any).window = new (global as any).Window(); - (global as any).Document = function Document() { }; - (global as any).document = undefined; // not an instance of Document - - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=Web")).eq(false); - }); - - it("is not Web when document is not instance of Document", async () => { - (global as any).Window = function Window() { }; - (global as any).window = new (global as any).Window(); - (global as any).Document = function Document() { }; - (global as any).document = {}; // Not an instance of Document - - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=Web")).eq(false); - }); - - it("is not Web when window is undefined", async () => { - (global as any).Window = function Window() { }; - (global as any).window = undefined; // not an instance of Window - (global as any).Document = function Document() { }; - (global as any).document = new (global as any).Document(); - - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=Web")).eq(false); - }); - - it("is not Web when window is not instance of Window", async () => { - (global as any).Window = function Window() { }; - (global as any).window = {}; // not an instance of Window - (global as any).Document = function Document() { }; - (global as any).document = new (global as any).Document(); - - try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions, - startupOptions: { - timeoutInMs: 1 - } - }); - } catch (e) { /* empty */ } - expect(headerPolicy.headers).not.undefined; - const correlationContext = headerPolicy.headers.get("Correlation-Context"); - expect(correlationContext).not.undefined; - expect(correlationContext.includes("Host=Web")).eq(false); - }); - }); -}); +// 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 { MAX_TIME_OUT, HttpRequestHeadersPolicy, createMockedConnectionString, createMockedKeyValue, createMockedFeatureFlag, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sinon, sleepInMs } from "./utils/testHelper.js"; +import { ConfigurationClientManager } from "../src/ConfigurationClientManager.js"; +import { load } from "./exportedApi.js"; + +const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; + +describe("request tracing", function () { + this.timeout(MAX_TIME_OUT); + + const fakeEndpoint = "https://127.0.0.1"; // sufficient to test the request it sends out + const headerPolicy = new HttpRequestHeadersPolicy(); + const position: "perCall" | "perRetry" = "perCall"; + const clientOptions = { + retryOptions: { + maxRetries: 0 // save time + }, + additionalPolicies: [{ + policy: headerPolicy, + position + }] + }; + + before(() => { + }); + + after(() => { + }); + + it("should have correct user agent prefix", async () => { + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); + }); + + it("should have request type in correlation-context header", async () => { + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(headerPolicy.headers.get("Correlation-Context")).eq("RequestType=Startup"); + }); + + it("should have key vault tag in correlation-context header", async () => { + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + keyVaultOptions: { + credential: createMockedTokenCredential() + }, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("UsesKeyVault")).eq(true); + }); + + it("should have loadbalancing tag in correlation-context header", async () => { + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + loadBalancingEnabled: true, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Features=LB")).eq(true); + }); + + it("should have replica count in correlation-context header", async () => { + const replicaCount = 2; + sinon.stub(ConfigurationClientManager.prototype, "getReplicaCount").returns(replicaCount); + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes(`ReplicaCount=${replicaCount}`)).eq(true); + sinon.restore(); + }); + + it("should detect env in correlation-context header", async () => { + process.env.NODE_ENV = "development"; + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Env=Dev")).eq(true); + delete process.env.NODE_ENV; + }); + + it("should detect host type in correlation-context header", async () => { + process.env.WEBSITE_SITE_NAME = "website-name"; + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=AzureWebApp")).eq(true); + delete process.env.WEBSITE_SITE_NAME; + }); + + it("should disable request tracing when AZURE_APP_CONFIGURATION_TRACING_DISABLED is true", async () => { + for (const indicator of ["TRUE", "true"]) { + process.env.AZURE_APP_CONFIGURATION_TRACING_DISABLED = indicator; + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).undefined; + } + + // 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: 1_000, + watchedSettings: [{ + key: "app.settings.fontColor" + }] + } + }); + await sleepInMs(1_000 + 1_000); + 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(); + }); + + it("should have filter type in correlation-context header if feature flags use feature filters", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { conditions: { client_filters: [ { name: "Microsoft.TimeWindow" } ] } }), + createMockedFeatureFlag("Alpha_2", { conditions: { client_filters: [ { name: "Microsoft.Targeting" } ] } }), + createMockedFeatureFlag("Alpha_3", { conditions: { client_filters: [ { name: "CustomFilter" } ] } }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1_000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1_000 + 1_000); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("Filter=CSTM+TIME+TRGT")).eq(true); + + restoreMocks(); + }); + + it("should have max variants in correlation-context header if feature flags use variants", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { variants: [ {name: "a"}, {name: "b"}] }), + createMockedFeatureFlag("Alpha_2", { variants: [ {name: "a"}, {name: "b"}, {name: "c"}] }), + createMockedFeatureFlag("Alpha_3", { variants: [] }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1_000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1_000 + 1_000); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("MaxVariants=3")).eq(true); + + restoreMocks(); + }); + + it("should have telemety tag in correlation-context header if feature flags enable telemetry", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { telemetry: {enabled: true} }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1_000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1_000 + 1_000); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("FFFeatures=Telemetry")).eq(true); + + restoreMocks(); + }); + + it("should have seed tag in correlation-context header if feature flags use allocation seed", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedFeatureFlag("Alpha_1", { telemetry: {enabled: true} }), + createMockedFeatureFlag("Alpha_2", { allocation: {seed: "123"} }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + featureFlagOptions: { + enabled: true, + selectors: [ {keyFilter: "*"} ], + refresh: { + enabled: true, + refreshIntervalInMs: 1_000 + } + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1_000 + 1_000); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Watch")).eq(true); + expect(correlationContext?.includes("FFFeatures=Seed+Telemetry")).eq(true); + + restoreMocks(); + }); + + it("should have AI tag in correlation-context header if key values use AI configuration", async () => { + let correlationContext: string = ""; + const listKvCallback = (listOptions) => { + correlationContext = listOptions?.requestOptions?.customHeaders[CORRELATION_CONTEXT_HEADER_NAME] ?? ""; + }; + + mockAppConfigurationClientListConfigurationSettings([[ + createMockedKeyValue({ contentType: "application/json; profile=\"https://azconfig.io/mime-profiles/ai/chat-completion\"" }) + ]], listKvCallback); + + const settings = await load(createMockedConnectionString(fakeEndpoint), { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 1000 + } + }); + + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("RequestType=Startup")).eq(true); + + await sleepInMs(1000 + 1); + try { + await settings.refresh(); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + expect(correlationContext).not.undefined; + expect(correlationContext?.includes("Features=AI+AICC")).eq(true); + + restoreMocks(); + }); + + describe("request tracing in Web Worker environment", () => { + let originalNavigator; + let originalWorkerNavigator; + let originalWorkerGlobalScope; + let originalImportScripts; + + before(() => { + // Save the original values to restore them later + originalNavigator = (global as any).navigator; + originalWorkerNavigator = (global as any).WorkerNavigator; + originalWorkerGlobalScope = (global as any).WorkerGlobalScope; + originalImportScripts = (global as any).importScripts; + }); + + afterEach(() => { + // Restore the original values after each test + // global.navigator was added in node 21, https://nodejs.org/api/globals.html#navigator_1 + // global.navigator only has a getter, so we have to use Object.defineProperty to modify it + Object.defineProperty(global, "navigator", { + value: originalNavigator, + configurable: true + }); + (global as any).WorkerNavigator = originalWorkerNavigator; + (global as any).WorkerGlobalScope = originalWorkerGlobalScope; + (global as any).importScripts = originalImportScripts; + }); + + it("should identify WebWorker environment", async () => { + (global as any).WorkerNavigator = function WorkerNavigator() { }; + Object.defineProperty(global, "navigator", { + value: new (global as any).WorkerNavigator(), + configurable: true + }); + (global as any).WorkerGlobalScope = function WorkerGlobalScope() { }; + (global as any).importScripts = function importScripts() { }; + + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=WebWorker")).eq(true); + }); + + it("is not WebWorker when WorkerNavigator is undefined", async () => { + Object.defineProperty(global, "navigator", { + value: { userAgent: "node.js" } as any, // Mock navigator + configurable: true + }); + (global as any).WorkerNavigator = undefined; + (global as any).WorkerGlobalScope = function WorkerGlobalScope() { }; + (global as any).importScripts = function importScripts() { }; + + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=WebWorker")).eq(false); + }); + + it("is not WebWorker when navigator is not an instance of WorkerNavigator", async () => { + Object.defineProperty(global, "navigator", { + value: { userAgent: "node.js" } as any, // Mock navigator but not an instance of WorkerNavigator + configurable: true + }); + (global as any).WorkerNavigator = function WorkerNavigator() { }; + (global as any).WorkerGlobalScope = function WorkerGlobalScope() { }; + (global as any).importScripts = function importScripts() { }; + + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=WebWorker")).eq(false); + }); + + it("is not WebWorker when WorkerGlobalScope is undefined", async () => { + (global as any).WorkerNavigator = function WorkerNavigator() { }; + Object.defineProperty(global, "navigator", { + value: new (global as any).WorkerNavigator(), + configurable: true + }); + (global as any).WorkerGlobalScope = undefined; + (global as any).importScripts = function importScripts() { }; + + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=WebWorker")).eq(false); + }); + + it("is not WebWorker when importScripts is undefined", async () => { + (global as any).WorkerNavigator = function WorkerNavigator() { }; + Object.defineProperty(global, "navigator", { + value: new (global as any).WorkerNavigator(), + configurable: true + }); + (global as any).WorkerGlobalScope = function WorkerGlobalScope() { }; + (global as any).importScripts = undefined; + + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=WebWorker")).eq(false); + }); + }); + + describe("request tracing in Web Browser environment", () => { + let originalWindowType; + let originalWindowObject; + let originalDocumentType; + let originalDocumentObject; + + before(() => { + // Save the original values to restore them later + originalWindowType = (global as any).Window; + originalWindowObject = (global as any).window; + originalDocumentType = (global as any).Document; + originalDocumentObject = (global as any).document; + }); + + afterEach(() => { + // Restore the original values after each test + (global as any).Window = originalWindowType; + (global as any).window = originalWindowObject; + (global as any).Document = originalDocumentType; + (global as any).document = originalDocumentObject; + }); + + it("should identify Web environment", async () => { + (global as any).Window = function Window() { }; + (global as any).window = new (global as any).Window(); + (global as any).Document = function Document() { }; + (global as any).document = new (global as any).Document(); + + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=Web")).eq(true); + }); + + it("is not Web when document is undefined", async () => { + (global as any).Window = function Window() { }; + (global as any).window = new (global as any).Window(); + (global as any).Document = function Document() { }; + (global as any).document = undefined; // not an instance of Document + + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=Web")).eq(false); + }); + + it("is not Web when document is not instance of Document", async () => { + (global as any).Window = function Window() { }; + (global as any).window = new (global as any).Window(); + (global as any).Document = function Document() { }; + (global as any).document = {}; // Not an instance of Document + + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=Web")).eq(false); + }); + + it("is not Web when window is undefined", async () => { + (global as any).Window = function Window() { }; + (global as any).window = undefined; // not an instance of Window + (global as any).Document = function Document() { }; + (global as any).document = new (global as any).Document(); + + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=Web")).eq(false); + }); + + it("is not Web when window is not instance of Window", async () => { + (global as any).Window = function Window() { }; + (global as any).window = {}; // not an instance of Window + (global as any).Document = function Document() { }; + (global as any).document = new (global as any).Document(); + + try { + await load(createMockedConnectionString(fakeEndpoint), { + clientOptions, + startupOptions: { + timeoutInMs: 1 + } + }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes("Host=Web")).eq(false); + }); + }); +}); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 85f7ac80..ff0d73c2 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -1,284 +1,284 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import * as sinon from "sinon"; -import { AppConfigurationClient, ConfigurationSetting, featureFlagContentType } from "@azure/app-configuration"; -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); -import * as crypto from "crypto"; -import { ConfigurationClientManager } from "../../src/ConfigurationClientManager.js"; -import { ConfigurationClientWrapper } from "../../src/ConfigurationClientWrapper.js"; - -const MAX_TIME_OUT = 20000; - -const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; -const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000"; -const TEST_CLIENT_SECRET = "0000000000000000000000000000000000000000"; - -function _sha256(input) { - return crypto.createHash("sha256").update(input).digest("hex"); -} - -function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { - const keyFilter = listOptions?.keyFilter ?? "*"; - const labelFilter = listOptions?.labelFilter ?? "*"; - return unfilteredKvs.filter(kv => { - const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter; - let labelMatched = false; - if (labelFilter === "*") { - labelMatched = true; - } else if (labelFilter === "\0") { - labelMatched = kv.label === undefined; - } else if (labelFilter.endsWith("*")) { - labelMatched = kv.label !== undefined && kv.label.startsWith(labelFilter.slice(0, -1)); - } else { - labelMatched = kv.label === labelFilter; - } - return keyMatched && labelMatched; - }); -} - -function getMockedIterator(pages: ConfigurationSetting[][], kvs: ConfigurationSetting[], listOptions: any) { - const mockIterator: AsyncIterableIterator & { byPage(): AsyncIterableIterator } = { - [Symbol.asyncIterator](): AsyncIterableIterator { - kvs = _filterKVs(pages.flat(), listOptions); - return this; - }, - next() { - const value = kvs.shift(); - return Promise.resolve({ done: !value, value }); - }, - byPage(): AsyncIterableIterator { - let remainingPages; - const pageEtags = listOptions?.pageEtags ? [...listOptions.pageEtags] : undefined; // a copy of the original list - return { - [Symbol.asyncIterator](): AsyncIterableIterator { - remainingPages = [...pages]; - return this; - }, - next() { - const pageItems = remainingPages.shift(); - const pageEtag = pageEtags?.shift(); - if (pageItems === undefined) { - return Promise.resolve({ done: true, value: undefined }); - } else { - const items = _filterKVs(pageItems ?? [], listOptions); - const etag = _sha256(JSON.stringify(items)); - const statusCode = pageEtag === etag ? 304 : 200; - return Promise.resolve({ - done: false, - value: { - items, - etag, - _response: { status: statusCode } - } - }); - } - } - }; - } - }; - - return mockIterator as any; -} - -/** - * Mocks the listConfigurationSettings method of AppConfigurationClient to return the provided pages of ConfigurationSetting. - * E.g. - * - mockAppConfigurationClientListConfigurationSettings([item1, item2, item3]) // single page - * - * @param pages List of pages, each page is a list of ConfigurationSetting - */ -function mockAppConfigurationClientListConfigurationSettings(pages: ConfigurationSetting[][], customCallback?: (listOptions) => any) { - - sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake((listOptions) => { - if (customCallback) { - customCallback(listOptions); - } - - const kvs = _filterKVs(pages.flat(), listOptions); - return getMockedIterator(pages, kvs, listOptions); - }); -} - -function mockAppConfigurationClientLoadBalanceMode(clientWrapper: ConfigurationClientWrapper, countObject: { count: number }) { - const emptyPages: ConfigurationSetting[][] = []; - sinon.stub(clientWrapper.client, "listConfigurationSettings").callsFake((listOptions) => { - countObject.count += 1; - const kvs = _filterKVs(emptyPages.flat(), listOptions); - return getMockedIterator(emptyPages, kvs, listOptions); - }); -} - -function mockConfigurationManagerGetClients(fakeClientWrappers: ConfigurationClientWrapper[], isFailoverable: boolean, ...pages: ConfigurationSetting[][]) { - // Stub the getClients method on the class prototype - sinon.stub(ConfigurationClientManager.prototype, "getClients").callsFake(async () => { - if (fakeClientWrappers?.length > 0) { - return fakeClientWrappers; - } - const clients: ConfigurationClientWrapper[] = []; - const fakeEndpoint = createMockedEndpoint("fake"); - const fakeStaticClientWrapper = new ConfigurationClientWrapper(fakeEndpoint, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint))); - sinon.stub(fakeStaticClientWrapper.client, "listConfigurationSettings").callsFake(() => { - throw new RestError("Internal Server Error", { statusCode: 500 }); - }); - clients.push(fakeStaticClientWrapper); - - if (!isFailoverable) { - return clients; - } - - const fakeReplicaEndpoint = createMockedEndpoint("fake-replica"); - const fakeDynamicClientWrapper = new ConfigurationClientWrapper(fakeReplicaEndpoint, new AppConfigurationClient(createMockedConnectionString(fakeReplicaEndpoint))); - clients.push(fakeDynamicClientWrapper); - sinon.stub(fakeDynamicClientWrapper.client, "listConfigurationSettings").callsFake((listOptions) => { - const kvs = _filterKVs(pages.flat(), listOptions); - return getMockedIterator(pages, kvs, listOptions); - }); - return clients; - }); -} - -function mockAppConfigurationClientGetConfigurationSetting(kvList, customCallback?: (options) => any) { - sinon.stub(AppConfigurationClient.prototype, "getConfigurationSetting").callsFake((settingId, options) => { - if (customCallback) { - customCallback(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(); - for (const [uri, value] of uriValueList) { - dict.set(uri, value); - } - - sinon.stub(SecretClient.prototype, "getSecret").callsFake(async function (secretName, options) { - const url = new URL(this.vaultUrl); - url.pathname = `/secrets/${secretName}`; - if (options?.version) { - url.pathname += `/${options.version}`; - } - return { - name: secretName, - value: dict.get(url.toString()) - } as KeyVaultSecret; - }); -} - -function restoreMocks() { - sinon.restore(); -} - -const createMockedEndpoint = (name = "azure") => `https://${name}.azconfig.io`; - -const createMockedConnectionString = (endpoint = createMockedEndpoint(), secret = "secret", id = "b1d9b31") => { - const toEncodeAsBytes = Buffer.from(secret); - const returnValue = toEncodeAsBytes.toString("base64"); - return `Endpoint=${endpoint};Id=${id};Secret=${returnValue}`; -}; - -const createMockedTokenCredential = (tenantId = TEST_TENANT_ID, clientId = TEST_CLIENT_ID, clientSecret = TEST_CLIENT_SECRET) => { - return new ClientSecretCredential(tenantId, clientId, clientSecret); -}; - -const createMockedKeyVaultReference = (key: string, vaultUri: string): ConfigurationSetting => ({ - // https://${vaultName}.vault.azure.net/secrets/${secretName} - value: `{"uri":"${vaultUri}"}`, - key, - contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", - lastModified: new Date(), - tags: { - }, - etag: uuid.v4(), - isReadOnly: false, -}); - -const createMockedJsonKeyValue = (key: string, value: any): ConfigurationSetting => ({ - value: value, - key: key, - contentType: "application/json", - lastModified: new Date(), - tags: {}, - etag: uuid.v4(), - isReadOnly: false -}); - -const createMockedKeyValue = (props: { [key: string]: any }): ConfigurationSetting => (Object.assign({ - value: "TestValue", - key: "TestKey", - contentType: "", - lastModified: new Date(), - tags: {}, - etag: uuid.v4(), - isReadOnly: false -}, props)); - -const createMockedFeatureFlag = (name: string, flagProps?: any, props?: any) => (Object.assign({ - key: `.appconfig.featureflag/${name}`, - value: JSON.stringify(Object.assign({ - "id": name, - "description": "", - "enabled": true, - "conditions": { - "client_filters": [] - } - }, flagProps)), - contentType: featureFlagContentType, - lastModified: new Date(), - tags: {}, - etag: uuid.v4(), - isReadOnly: false -}, props)); - -class HttpRequestHeadersPolicy { - headers: any; - name: string; - - constructor() { - this.headers = {}; - this.name = "HttpRequestHeadersPolicy"; - } - sendRequest(req, next) { - this.headers = req.headers; - return next(req).then(resp => resp); - } -} - -export { - sinon, - mockAppConfigurationClientListConfigurationSettings, - mockAppConfigurationClientGetConfigurationSetting, - mockAppConfigurationClientLoadBalanceMode, - mockConfigurationManagerGetClients, - mockSecretClientGetSecret, - restoreMocks, - - createMockedEndpoint, - createMockedConnectionString, - createMockedTokenCredential, - createMockedKeyVaultReference, - createMockedJsonKeyValue, - createMockedKeyValue, - createMockedFeatureFlag, - - sleepInMs, - MAX_TIME_OUT, - HttpRequestHeadersPolicy -}; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as sinon from "sinon"; +import { AppConfigurationClient, ConfigurationSetting, featureFlagContentType } from "@azure/app-configuration"; +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); +import * as crypto from "crypto"; +import { ConfigurationClientManager } from "../../src/ConfigurationClientManager.js"; +import { ConfigurationClientWrapper } from "../../src/ConfigurationClientWrapper.js"; + +const MAX_TIME_OUT = 20000; + +const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; +const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000"; +const TEST_CLIENT_SECRET = "0000000000000000000000000000000000000000"; + +function _sha256(input) { + return crypto.createHash("sha256").update(input).digest("hex"); +} + +function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { + const keyFilter = listOptions?.keyFilter ?? "*"; + const labelFilter = listOptions?.labelFilter ?? "*"; + return unfilteredKvs.filter(kv => { + const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter; + let labelMatched = false; + if (labelFilter === "*") { + labelMatched = true; + } else if (labelFilter === "\0") { + labelMatched = kv.label === undefined; + } else if (labelFilter.endsWith("*")) { + labelMatched = kv.label !== undefined && kv.label.startsWith(labelFilter.slice(0, -1)); + } else { + labelMatched = kv.label === labelFilter; + } + return keyMatched && labelMatched; + }); +} + +function getMockedIterator(pages: ConfigurationSetting[][], kvs: ConfigurationSetting[], listOptions: any) { + const mockIterator: AsyncIterableIterator & { byPage(): AsyncIterableIterator } = { + [Symbol.asyncIterator](): AsyncIterableIterator { + kvs = _filterKVs(pages.flat(), listOptions); + return this; + }, + next() { + const value = kvs.shift(); + return Promise.resolve({ done: !value, value }); + }, + byPage(): AsyncIterableIterator { + let remainingPages; + const pageEtags = listOptions?.pageEtags ? [...listOptions.pageEtags] : undefined; // a copy of the original list + return { + [Symbol.asyncIterator](): AsyncIterableIterator { + remainingPages = [...pages]; + return this; + }, + next() { + const pageItems = remainingPages.shift(); + const pageEtag = pageEtags?.shift(); + if (pageItems === undefined) { + return Promise.resolve({ done: true, value: undefined }); + } else { + const items = _filterKVs(pageItems ?? [], listOptions); + const etag = _sha256(JSON.stringify(items)); + const statusCode = pageEtag === etag ? 304 : 200; + return Promise.resolve({ + done: false, + value: { + items, + etag, + _response: { status: statusCode } + } + }); + } + } + }; + } + }; + + return mockIterator as any; +} + +/** + * Mocks the listConfigurationSettings method of AppConfigurationClient to return the provided pages of ConfigurationSetting. + * E.g. + * - mockAppConfigurationClientListConfigurationSettings([item1, item2, item3]) // single page + * + * @param pages List of pages, each page is a list of ConfigurationSetting + */ +function mockAppConfigurationClientListConfigurationSettings(pages: ConfigurationSetting[][], customCallback?: (listOptions) => any) { + + sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake((listOptions) => { + if (customCallback) { + customCallback(listOptions); + } + + const kvs = _filterKVs(pages.flat(), listOptions); + return getMockedIterator(pages, kvs, listOptions); + }); +} + +function mockAppConfigurationClientLoadBalanceMode(clientWrapper: ConfigurationClientWrapper, countObject: { count: number }) { + const emptyPages: ConfigurationSetting[][] = []; + sinon.stub(clientWrapper.client, "listConfigurationSettings").callsFake((listOptions) => { + countObject.count += 1; + const kvs = _filterKVs(emptyPages.flat(), listOptions); + return getMockedIterator(emptyPages, kvs, listOptions); + }); +} + +function mockConfigurationManagerGetClients(fakeClientWrappers: ConfigurationClientWrapper[], isFailoverable: boolean, ...pages: ConfigurationSetting[][]) { + // Stub the getClients method on the class prototype + sinon.stub(ConfigurationClientManager.prototype, "getClients").callsFake(async () => { + if (fakeClientWrappers?.length > 0) { + return fakeClientWrappers; + } + const clients: ConfigurationClientWrapper[] = []; + const fakeEndpoint = createMockedEndpoint("fake"); + const fakeStaticClientWrapper = new ConfigurationClientWrapper(fakeEndpoint, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint))); + sinon.stub(fakeStaticClientWrapper.client, "listConfigurationSettings").callsFake(() => { + throw new RestError("Internal Server Error", { statusCode: 500 }); + }); + clients.push(fakeStaticClientWrapper); + + if (!isFailoverable) { + return clients; + } + + const fakeReplicaEndpoint = createMockedEndpoint("fake-replica"); + const fakeDynamicClientWrapper = new ConfigurationClientWrapper(fakeReplicaEndpoint, new AppConfigurationClient(createMockedConnectionString(fakeReplicaEndpoint))); + clients.push(fakeDynamicClientWrapper); + sinon.stub(fakeDynamicClientWrapper.client, "listConfigurationSettings").callsFake((listOptions) => { + const kvs = _filterKVs(pages.flat(), listOptions); + return getMockedIterator(pages, kvs, listOptions); + }); + return clients; + }); +} + +function mockAppConfigurationClientGetConfigurationSetting(kvList, customCallback?: (options) => any) { + sinon.stub(AppConfigurationClient.prototype, "getConfigurationSetting").callsFake((settingId, options) => { + if (customCallback) { + customCallback(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(); + for (const [uri, value] of uriValueList) { + dict.set(uri, value); + } + + sinon.stub(SecretClient.prototype, "getSecret").callsFake(async function (secretName, options) { + const url = new URL(this.vaultUrl); + url.pathname = `/secrets/${secretName}`; + if (options?.version) { + url.pathname += `/${options.version}`; + } + return { + name: secretName, + value: dict.get(url.toString()) + } as KeyVaultSecret; + }); +} + +function restoreMocks() { + sinon.restore(); +} + +const createMockedEndpoint = (name = "azure") => `https://${name}.azconfig.io`; + +const createMockedConnectionString = (endpoint = createMockedEndpoint(), secret = "secret", id = "b1d9b31") => { + const toEncodeAsBytes = Buffer.from(secret); + const returnValue = toEncodeAsBytes.toString("base64"); + return `Endpoint=${endpoint};Id=${id};Secret=${returnValue}`; +}; + +const createMockedTokenCredential = (tenantId = TEST_TENANT_ID, clientId = TEST_CLIENT_ID, clientSecret = TEST_CLIENT_SECRET) => { + return new ClientSecretCredential(tenantId, clientId, clientSecret); +}; + +const createMockedKeyVaultReference = (key: string, vaultUri: string): ConfigurationSetting => ({ + // https://${vaultName}.vault.azure.net/secrets/${secretName} + value: `{"uri":"${vaultUri}"}`, + key, + contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", + lastModified: new Date(), + tags: { + }, + etag: uuid.v4(), + isReadOnly: false, +}); + +const createMockedJsonKeyValue = (key: string, value: any): ConfigurationSetting => ({ + value: value, + key: key, + contentType: "application/json", + lastModified: new Date(), + tags: {}, + etag: uuid.v4(), + isReadOnly: false +}); + +const createMockedKeyValue = (props: { [key: string]: any }): ConfigurationSetting => (Object.assign({ + value: "TestValue", + key: "TestKey", + contentType: "", + lastModified: new Date(), + tags: {}, + etag: uuid.v4(), + isReadOnly: false +}, props)); + +const createMockedFeatureFlag = (name: string, flagProps?: any, props?: any) => (Object.assign({ + key: `.appconfig.featureflag/${name}`, + value: JSON.stringify(Object.assign({ + "id": name, + "description": "", + "enabled": true, + "conditions": { + "client_filters": [] + } + }, flagProps)), + contentType: featureFlagContentType, + lastModified: new Date(), + tags: {}, + etag: uuid.v4(), + isReadOnly: false +}, props)); + +class HttpRequestHeadersPolicy { + headers: any; + name: string; + + constructor() { + this.headers = {}; + this.name = "HttpRequestHeadersPolicy"; + } + sendRequest(req, next) { + this.headers = req.headers; + return next(req).then(resp => resp); + } +} + +export { + sinon, + mockAppConfigurationClientListConfigurationSettings, + mockAppConfigurationClientGetConfigurationSetting, + mockAppConfigurationClientLoadBalanceMode, + mockConfigurationManagerGetClients, + mockSecretClientGetSecret, + restoreMocks, + + createMockedEndpoint, + createMockedConnectionString, + createMockedTokenCredential, + createMockedKeyVaultReference, + createMockedJsonKeyValue, + createMockedKeyValue, + createMockedFeatureFlag, + + sleepInMs, + MAX_TIME_OUT, + HttpRequestHeadersPolicy +}; diff --git a/tsconfig.base.json b/tsconfig.base.json index 0f96a196..50b539c2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,24 +1,24 @@ -{ - "compilerOptions": { - "lib": [ - "DOM", - "WebWorker", - "ESNext" - ], - "skipDefaultLibCheck": true, - "module": "ESNext", - "moduleResolution": "Node", - "target": "ES2022", - "strictNullChecks": true, - "strictFunctionTypes": true, - "sourceMap": true, - "inlineSources": true - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "**/node_modules/*" - ] +{ + "compilerOptions": { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "**/node_modules/*" + ] } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index f2fc0e9b..9d32fd42 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "module": "ESNext", - "outDir": "./dist-esm" - }, - "include": [ - "src/**/*" - ] +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "outDir": "./dist-esm" + }, + "include": [ + "src/**/*" + ] } \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json index 3cbd3c0a..cc0d26fd 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,10 +1,10 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "module": "CommonJS", - "outDir": "./out" - }, - "include": [ - "test/**/*" - ] +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./out" + }, + "include": [ + "test/**/*" + ] } \ No newline at end of file From 986e3f0b537b9af59b23d81c1672f8ea265635ca Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 14 May 2025 11:15:20 +0800 Subject: [PATCH 6/9] Resolve key vault secret in parallel (#192) * get secret in parallel * remove test project * add parallelSecretResolutionEnabled option * fix lint --- src/AzureAppConfigurationImpl.ts | 29 +++++++++++++++++++++++++---- src/keyvault/KeyVaultOptions.ts | 8 ++++++++ test/keyvault.test.ts | 12 ++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 1491b806..f3c84061 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag } from "@azure/app-configuration"; +import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag, isSecretReference } from "@azure/app-configuration"; import { isRestError } from "@azure/core-rest-pipeline"; import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; @@ -83,6 +83,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #ffRefreshTimer: RefreshTimer; + // Key Vault references + #resolveSecretInParallel: boolean = false; + /** * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors */ @@ -163,6 +166,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } + if (options?.keyVaultOptions?.parallelSecretResolutionEnabled) { + this.#resolveSecretInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled; + } + this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); this.#adapters.push(new JsonKeyValueAdapter()); } @@ -484,7 +491,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async #loadSelectedAndWatchedKeyValues() { const keyValues: [key: string, value: unknown][] = []; - const loadedSettings = await this.#loadConfigurationSettings(); + const loadedSettings: ConfigurationSetting[] = await this.#loadConfigurationSettings(); if (this.#refreshEnabled && !this.#watchAll) { await this.#updateWatchedKeyValuesEtag(loadedSettings); } @@ -494,11 +501,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#aiConfigurationTracing.reset(); } - // adapt configuration settings to key-values + const secretResolutionPromises: Promise[] = []; for (const setting of loadedSettings) { + if (this.#resolveSecretInParallel && isSecretReference(setting)) { + // secret references are resolved asynchronously to improve performance + const secretResolutionPromise = this.#processKeyValue(setting) + .then(([key, value]) => { + keyValues.push([key, value]); + }); + secretResolutionPromises.push(secretResolutionPromise); + continue; + } + // adapt configuration settings to key-values const [key, value] = await this.#processKeyValue(setting); keyValues.push([key, value]); } + if (secretResolutionPromises.length > 0) { + // wait for all secret resolution promises to be resolved + await Promise.all(secretResolutionPromises); + } this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion for (const [k, v] of keyValues) { @@ -543,7 +564,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async #loadFeatureFlags() { const loadFeatureFlag = true; - const featureFlagSettings = await this.#loadConfigurationSettings(loadFeatureFlag); + const featureFlagSettings: ConfigurationSetting[] = await this.#loadConfigurationSettings(loadFeatureFlag); if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { // Reset old feature flag tracing in order to track the information present in the current response from server. diff --git a/src/keyvault/KeyVaultOptions.ts b/src/keyvault/KeyVaultOptions.ts index 132c9cf5..3cf4bad0 100644 --- a/src/keyvault/KeyVaultOptions.ts +++ b/src/keyvault/KeyVaultOptions.ts @@ -32,4 +32,12 @@ export interface KeyVaultOptions { * @returns The secret value. */ secretResolver?: (keyVaultReference: URL) => string | Promise; + + /** + * Specifies whether to resolve the secret value in parallel. + * + * @remarks + * If not specified, the default value is false. + */ + parallelSecretResolutionEnabled?: boolean; } diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index 81dc429d..8fd15a19 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -127,4 +127,16 @@ describe("key vault reference", function () { expect(settings.get("TestKey")).eq("SecretValue"); expect(settings.get("TestKey2")).eq("SecretValue2"); }); + + it("should resolve key vault reference in parallel", async () => { + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + credential: createMockedTokenCredential(), + parallelSecretResolutionEnabled: true + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretValue"); + expect(settings.get("TestKeyFixedVersion")).eq("OldSecretValue"); + }); }); From 9861d03f8b29dc46f78a14325a49be7d11522d60 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 14 May 2025 15:34:01 +0800 Subject: [PATCH 7/9] remove variable (#197) --- src/AzureAppConfigurationImpl.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index f3c84061..5d356af0 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -84,7 +84,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #ffRefreshTimer: RefreshTimer; // Key Vault references - #resolveSecretInParallel: boolean = false; + #resolveSecretsInParallel: boolean = false; /** * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors @@ -167,7 +167,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (options?.keyVaultOptions?.parallelSecretResolutionEnabled) { - this.#resolveSecretInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled; + this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled; } this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); @@ -503,7 +503,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const secretResolutionPromises: Promise[] = []; for (const setting of loadedSettings) { - if (this.#resolveSecretInParallel && isSecretReference(setting)) { + if (this.#resolveSecretsInParallel && isSecretReference(setting)) { // secret references are resolved asynchronously to improve performance const secretResolutionPromise = this.#processKeyValue(setting) .then(([key, value]) => { From ddd19e0ad2c4bb451bb3ff319c970c855a3c05b0 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 22 May 2025 12:46:08 +0800 Subject: [PATCH 8/9] Support load snapshot (#140) * support snapshot * add testcase * add testcase * fix lint * update * update test * update testcase * add more testcases * update * update error type --- src/AzureAppConfigurationImpl.ts | 143 ++++++++++++++++++++++++------- src/requestTracing/utils.ts | 47 ++++++---- src/types.ts | 11 ++- test/featureFlag.test.ts | 23 ++++- test/load.test.ts | 37 +++++++- test/utils/testHelper.ts | 31 +++++++ 6 files changed, 240 insertions(+), 52 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 5d356af0..fc8759b4 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,7 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag, isSecretReference } from "@azure/app-configuration"; +import { + AppConfigurationClient, + ConfigurationSetting, + ConfigurationSettingId, + GetConfigurationSettingOptions, + GetConfigurationSettingResponse, + ListConfigurationSettingsOptions, + featureFlagPrefix, + isFeatureFlag, + isSecretReference, + GetSnapshotOptions, + GetSnapshotResponse, + KnownSnapshotComposition +} from "@azure/app-configuration"; import { isRestError } from "@azure/core-rest-pipeline"; import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; @@ -29,7 +42,14 @@ import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } fro import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/RefreshTimer.js"; -import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; +import { + RequestTracingOptions, + getConfigurationSettingWithTrace, + listConfigurationSettingsWithTrace, + getSnapshotWithTrace, + listConfigurationSettingsForSnapshotWithTrace, + requestTracingEnabled +} from "./requestTracing/utils.js"; import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js"; import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; @@ -453,26 +473,49 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ); for (const selector of selectorsToUpdate) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter - }; - - const pageEtags: string[] = []; - const pageIterator = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - client, - listOptions - ).byPage(); - for await (const page of pageIterator) { - pageEtags.push(page.etag ?? ""); - for (const setting of page.items) { - if (loadFeatureFlag === isFeatureFlag(setting)) { - loadedSettings.push(setting); + if (selector.snapshotName === undefined) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: selector.keyFilter, + labelFilter: selector.labelFilter + }; + const pageEtags: string[] = []; + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + client, + listOptions + ).byPage(); + + for await (const page of pageIterator) { + pageEtags.push(page.etag ?? ""); + for (const setting of page.items) { + if (loadFeatureFlag === isFeatureFlag(setting)) { + loadedSettings.push(setting); + } + } + } + selector.pageEtags = pageEtags; + } else { // snapshot selector + const snapshot = await this.#getSnapshot(selector.snapshotName); + if (snapshot === undefined) { + throw new InvalidOperationError(`Could not find snapshot with name ${selector.snapshotName}.`); + } + if (snapshot.compositionType != KnownSnapshotComposition.Key) { + throw new InvalidOperationError(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`); + } + const pageIterator = listConfigurationSettingsForSnapshotWithTrace( + this.#requestTraceOptions, + client, + selector.snapshotName + ).byPage(); + + for await (const page of pageIterator) { + for (const setting of page.items) { + if (loadFeatureFlag === isFeatureFlag(setting)) { + loadedSettings.push(setting); + } } } } - selector.pageEtags = pageEtags; } if (loadFeatureFlag) { @@ -644,6 +687,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise { const funcToExecute = async (client) => { for (const selector of selectors) { + if (selector.snapshotName) { // skip snapshot selector + continue; + } const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, @@ -695,6 +741,29 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return response; } + async #getSnapshot(snapshotName: string, customOptions?: GetSnapshotOptions): Promise { + const funcToExecute = async (client) => { + return getSnapshotWithTrace( + this.#requestTraceOptions, + client, + snapshotName, + customOptions + ); + }; + + let response: GetSnapshotResponse | undefined; + try { + response = await this.#executeWithFailoverPolicy(funcToExecute); + } catch (error) { + if (isRestError(error) && error.statusCode === 404) { + response = undefined; + } else { + throw error; + } + } + return response; + } + // Only operations related to Azure App Configuration should be executed with failover policy. async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { let clientWrappers = await this.#clientManager.getClients(); @@ -838,11 +907,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } -function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { - // below code deduplicates selectors by keyFilter and labelFilter, the latter selector wins +function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] { + // below code deduplicates selectors, the latter selector wins const uniqueSelectors: SettingSelector[] = []; for (const selector of selectors) { - const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter); + const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter && s.snapshotName === selector.snapshotName); if (existingSelectorIndex >= 0) { uniqueSelectors.splice(existingSelectorIndex, 1); } @@ -851,14 +920,20 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { return uniqueSelectors.map(selectorCandidate => { const selector = { ...selectorCandidate }; - if (!selector.keyFilter) { - throw new ArgumentError("Key filter cannot be null or empty."); - } - if (!selector.labelFilter) { - selector.labelFilter = LabelFilter.Null; - } - if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { - throw new ArgumentError("The characters '*' and ',' are not supported in label filters."); + if (selector.snapshotName) { + if (selector.keyFilter || selector.labelFilter) { + throw new ArgumentError("Key or label filter should not be used for a snapshot."); + } + } else { + if (!selector.keyFilter) { + throw new ArgumentError("Key filter cannot be null or empty."); + } + if (!selector.labelFilter) { + selector.labelFilter = LabelFilter.Null; + } + if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { + throw new ArgumentError("The characters '*' and ',' are not supported in label filters."); + } } return selector; }); @@ -869,7 +944,7 @@ function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelect // Default selector: key: *, label: \0 return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; } - return getValidSelectors(selectors); + return getValidSettingSelectors(selectors); } function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] { @@ -878,7 +953,9 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel return [{ keyFilter: `${featureFlagPrefix}${KeyFilter.Any}`, labelFilter: LabelFilter.Null }]; } selectors.forEach(selector => { - selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`; + if (selector.keyFilter) { + selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`; + } }); - return getValidSelectors(selectors); + return getValidSettingSelectors(selectors); } diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index 6abd4497..af6ef0b8 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -1,7 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions } from "@azure/app-configuration"; +import { OperationOptions } from "@azure/core-client"; +import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions, GetSnapshotOptions, ListConfigurationSettingsForSnapshotOptions } from "@azure/app-configuration"; import { AzureAppConfigurationOptions } from "../AzureAppConfigurationOptions.js"; import { FeatureFlagTracingOptions } from "./FeatureFlagTracingOptions.js"; import { AIConfigurationTracingOptions } from "./AIConfigurationTracingOptions.js"; @@ -52,15 +53,7 @@ export function listConfigurationSettingsWithTrace( client: AppConfigurationClient, listOptions: ListConfigurationSettingsOptions ) { - const actualListOptions = { ...listOptions }; - if (requestTracingOptions.enabled) { - actualListOptions.requestOptions = { - customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) - } - }; - } - + const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions); return client.listConfigurationSettings(actualListOptions); } @@ -70,20 +63,43 @@ export function getConfigurationSettingWithTrace( configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions, ) { - const actualGetOptions = { ...getOptions }; + const actualGetOptions = applyRequestTracing(requestTracingOptions, getOptions); + return client.getConfigurationSetting(configurationSettingId, actualGetOptions); +} + +export function getSnapshotWithTrace( + requestTracingOptions: RequestTracingOptions, + client: AppConfigurationClient, + snapshotName: string, + getOptions?: GetSnapshotOptions +) { + const actualGetOptions = applyRequestTracing(requestTracingOptions, getOptions); + return client.getSnapshot(snapshotName, actualGetOptions); +} +export function listConfigurationSettingsForSnapshotWithTrace( + requestTracingOptions: RequestTracingOptions, + client: AppConfigurationClient, + snapshotName: string, + listOptions?: ListConfigurationSettingsForSnapshotOptions +) { + const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions); + return client.listConfigurationSettingsForSnapshot(snapshotName, actualListOptions); +} + +function applyRequestTracing(requestTracingOptions: RequestTracingOptions, operationOptions?: T) { + const actualOptions = { ...operationOptions }; if (requestTracingOptions.enabled) { - actualGetOptions.requestOptions = { + actualOptions.requestOptions = { customHeaders: { [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) } }; } - - return client.getConfigurationSetting(configurationSettingId, actualGetOptions); + return actualOptions; } -export function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string { +function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string { /* RequestType: 'Startup' during application starting up, 'Watch' after startup completed. Host: identify with defined envs @@ -227,4 +243,3 @@ export function isWebWorker() { return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected; } - diff --git a/src/types.ts b/src/types.ts index faa15285..bef8b6b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,7 +17,7 @@ export type SettingSelector = { * 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, + keyFilter?: string, /** * The label filter to apply when querying Azure App Configuration for key-values. @@ -29,6 +29,15 @@ export type SettingSelector = { * @defaultValue `LabelFilter.Null`, matching key-values without a label. */ labelFilter?: string + + /** + * The name of snapshot to load from App Configuration. + * + * @remarks + * Snapshot is a set of key-values selected from the App Configuration store based on the composition type and filters. Once created, it is stored as an immutable entity that can be referenced by name. + * If snapshot name is used in a selector, no key and label filter should be used for it. Otherwise, an exception will be thrown. + */ + snapshotName?: string }; /** diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 605e5292..14586cf7 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -5,7 +5,7 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; import { featureFlagContentType } from "@azure/app-configuration"; import { load } from "./exportedApi.js"; -import { MAX_TIME_OUT, createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, mockAppConfigurationClientGetSnapshot, mockAppConfigurationClientListConfigurationSettingsForSnapshot, createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js"; chai.use(chaiAsPromised); const expect = chai.expect; @@ -337,4 +337,25 @@ describe("feature flags", function () { expect(featureFlag.telemetry.metadata.ETag).equals("ETag"); expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); }); + + it("should load feature flags from snapshot", async () => { + const snapshotName = "Test"; + mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); + mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[createMockedFeatureFlag("TestFeature", { enabled: true })]]); + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ { snapshotName: snapshotName } ] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect((featureFlags as []).length).equals(1); + const featureFlag = featureFlags[0]; + expect(featureFlag.id).equals("TestFeature"); + expect(featureFlag.enabled).equals(true); + restoreMocks(); + }); }); diff --git a/test/load.test.ts b/test/load.test.ts index be6ebba7..7806789d 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi.js"; -import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetSnapshot, mockAppConfigurationClientListConfigurationSettingsForSnapshot, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; const mockedKVs = [{ key: "app.settings.fontColor", @@ -122,6 +122,25 @@ describe("load", function () { return expect(load("invalid-endpoint-url", credential)).eventually.rejectedWith("Invalid URL"); }); + it("should throw error given invalid selector", async () => { + const connectionString = createMockedConnectionString(); + return expect(load(connectionString, { + selectors: [{ + labelFilter: "\0" + }] + })).eventually.rejectedWith("Key filter cannot be null or empty."); + }); + + it("should throw error given invalid snapshot selector", async () => { + const connectionString = createMockedConnectionString(); + return expect(load(connectionString, { + selectors: [{ + snapshotName: "Test", + labelFilter: "\0" + }] + })).eventually.rejectedWith("Key or label filter should not be used for a snapshot."); + }); + it("should not include feature flags directly in the settings", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString); @@ -418,4 +437,20 @@ describe("load", function () { settings.constructConfigurationObject({ separator: "%" }); }).to.throw("Invalid separator '%'. Supported values: '.', ',', ';', '-', '_', '__', '/', ':'."); }); + + it("should load key values from snapshot", async () => { + const snapshotName = "Test"; + mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); + mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)]); + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + snapshotName: snapshotName + }] + }); + expect(settings).not.undefined; + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("TestValue"); + restoreMocks(); + }); }); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index ff0d73c2..6b1baca4 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -162,6 +162,35 @@ function mockAppConfigurationClientGetConfigurationSetting(kvList, customCallbac }); } +function mockAppConfigurationClientGetSnapshot(snapshotName: string, mockedResponse: any, customCallback?: (options) => any) { + sinon.stub(AppConfigurationClient.prototype, "getSnapshot").callsFake((name, options) => { + if (customCallback) { + customCallback(options); + } + + if (name === snapshotName) { + return mockedResponse; + } else { + throw new RestError("", { statusCode: 404 }); + } + }); +} + +function mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName: string, pages: ConfigurationSetting[][], customCallback?: (options) => any) { + sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettingsForSnapshot").callsFake((name, listOptions) => { + if (customCallback) { + customCallback(listOptions); + } + + if (name === snapshotName) { + const kvs = _filterKVs(pages.flat(), listOptions); + return getMockedIterator(pages, kvs, listOptions); + } else { + throw new RestError("", { statusCode: 404 }); + } + }); +} + // uriValueList: [["", "value"], ...] function mockSecretClientGetSecret(uriValueList: [string, string][]) { const dict = new Map(); @@ -265,6 +294,8 @@ export { sinon, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, + mockAppConfigurationClientGetSnapshot, + mockAppConfigurationClientListConfigurationSettingsForSnapshot, mockAppConfigurationClientLoadBalanceMode, mockConfigurationManagerGetClients, mockSecretClientGetSecret, From 5227eb5be46d136d2f0877a6d44de48ad0643e98 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 22 May 2025 13:37:02 +0800 Subject: [PATCH 9/9] version bump 2.1.0 (#198) --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index b4979eed..550e7f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure/app-configuration-provider", - "version": "2.0.2", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure/app-configuration-provider", - "version": "2.0.2", + "version": "2.1.0", "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.6.1", diff --git a/package.json b/package.json index c528c521..4ac941b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure/app-configuration-provider", - "version": "2.0.2", + "version": "2.1.0", "description": "The JavaScript configuration provider for Azure App Configuration", "main": "dist/index.js", "module": "./dist-esm/index.js", diff --git a/src/version.ts b/src/version.ts index 92cdac8c..0200538f 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "2.0.2"; +export const VERSION = "2.1.0";