From 1f41e0d0c6ba7b1a8048bd50a13b5a0e46aec22e Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 19 Dec 2024 16:40:17 +0800 Subject: [PATCH 01/10] support snapshot --- src/AzureAppConfigurationImpl.ts | 138 ++++++++++++++++++++++++------- src/requestTracing/utils.ts | 47 +++++++---- src/types.ts | 9 ++ 3 files changed, 146 insertions(+), 48 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 916ece49..dedd7b27 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,7 +1,19 @@ // 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, + 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"; @@ -35,7 +47,14 @@ import { } from "./featureManagement/constants.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 { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; @@ -363,26 +382,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 Error(`Could not find snapshot with name ${selector.snapshotName}.`); + } + if (snapshot.compositionType != KnownSnapshotComposition.Key) { + throw new Error(`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) { @@ -530,6 +572,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, @@ -581,6 +626,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; + } + async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { let clientWrappers = await this.#clientManager.getClients(); if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) { @@ -862,11 +930,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); } @@ -875,14 +943,20 @@ 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."); - } - 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."); + if (selector.snapshotName) { + if (selector.keyFilter || selector.labelFilter) { + throw new Error("Key or label filter should not be used for a snapshot."); + } + } else { + if (!selector.keyFilter) { + throw new Error("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."); + } } return selector; }); @@ -893,7 +967,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[] { @@ -904,7 +978,7 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel selectors.forEach(selector => { selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`; }); - return getValidSelectors(selectors); + return getValidSettingSelectors(selectors); } } diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index b56c460c..28f2c5f5 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 { @@ -45,15 +46,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); } @@ -63,20 +56,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 @@ -200,4 +216,3 @@ export function isWebWorker() { return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected; } - diff --git a/src/types.ts b/src/types.ts index a8181378..c50a5a28 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 }; /** From a331e986082216a50b296afb86f82f3e1922f288 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Fri, 20 Dec 2024 13:27:33 +0800 Subject: [PATCH 02/10] add testcase --- src/types.ts | 2 +- test/load.test.ts | 18 +++++++++++++++++- test/utils/testHelper.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/types.ts b/src/types.ts index c50a5a28..8ba89257 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. diff --git a/test/load.test.ts b/test/load.test.ts index d36a3311..ebd27bf6 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -6,7 +6,8 @@ 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"; +import { mock } from "node:test"; const mockedKVs = [{ key: "app.settings.fontColor", @@ -418,4 +419,19 @@ describe("load", function () { settings.constructConfigurationObject({ separator: "%" }); }).to.throw("Invalid separator '%'. Supported values: '.', ',', ';', '-', '_', '__', '/', ':'."); }); + + it("should load 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"); + }); }); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index a5812694..bc816fea 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 7d98b58b0fa7cd9e5aa12604da139951b928e6e9 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Fri, 20 Dec 2024 14:17:53 +0800 Subject: [PATCH 03/10] add testcase --- test/load.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/load.test.ts b/test/load.test.ts index ebd27bf6..9d27358f 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -433,5 +433,18 @@ describe("load", function () { expect(settings).not.undefined; expect(settings).not.undefined; expect(settings.get("TestKey")).eq("TestValue"); + restoreMocks(); + }); + + it("should throw error when snapshot composition type is not key", async () => { + const snapshotName = "Test"; + mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key_label"}); + const connectionString = createMockedConnectionString(); + expect(load(connectionString, { + selectors: [{ + snapshotName: snapshotName + }] + })).eventually.rejectedWith(`Composition type for the selected snapshot with name ${snapshotName} must be 'key'.`); + restoreMocks(); }); }); From 9bffa883569f55674b731beee49445e0f4477f1e Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Fri, 20 Dec 2024 15:14:28 +0800 Subject: [PATCH 04/10] fix lint --- test/load.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/load.test.ts b/test/load.test.ts index 9d27358f..b0f39985 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -7,7 +7,6 @@ chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi.js"; import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetSnapshot, mockAppConfigurationClientListConfigurationSettingsForSnapshot, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; -import { mock } from "node:test"; const mockedKVs = [{ key: "app.settings.fontColor", From 28c2d0ec6887991d79d074d5032d2d065ee0bacc Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 24 Dec 2024 16:34:37 +0800 Subject: [PATCH 05/10] update --- src/AzureAppConfigurationImpl.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index dedd7b27..2ab8d64e 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -975,10 +975,11 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel // selectors must be explicitly provided. throw new Error("Feature flag selectors must be provided."); } else { - selectors.forEach(selector => { + const validSelectors = getValidSettingSelectors(selectors); + validSelectors.forEach(selector => { selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`; }); - return getValidSettingSelectors(selectors); + return validSelectors; } } From 994c10ec784bfa64f80eb9adacc4f931921d6ea6 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 23 Apr 2025 19:09:37 +0800 Subject: [PATCH 06/10] update test --- test/load.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/load.test.ts b/test/load.test.ts index b0f39985..2dfd791e 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -439,11 +439,11 @@ describe("load", function () { const snapshotName = "Test"; mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key_label"}); const connectionString = createMockedConnectionString(); - expect(load(connectionString, { + await expect(load(connectionString, { selectors: [{ snapshotName: snapshotName }] - })).eventually.rejectedWith(`Composition type for the selected snapshot with name ${snapshotName} must be 'key'.`); + })).to.eventually.be.rejectedWith(`Composition type for the selected snapshot with name ${snapshotName} must be 'key'.`); restoreMocks(); }); }); From cad828aca40e8324b8b970de94d774d7384f32e4 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Tue, 29 Apr 2025 13:52:12 +0800 Subject: [PATCH 07/10] update testcase --- test/load.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/test/load.test.ts b/test/load.test.ts index d4e7232e..e3f662d5 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -434,16 +434,4 @@ describe("load", function () { expect(settings.get("TestKey")).eq("TestValue"); restoreMocks(); }); - - it("should throw error when snapshot composition type is not key", async () => { - const snapshotName = "Test"; - mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key_label"}); - const connectionString = createMockedConnectionString(); - await expect(load(connectionString, { - selectors: [{ - snapshotName: snapshotName - }] - })).to.eventually.be.rejectedWith(`Composition type for the selected snapshot with name ${snapshotName} must be 'key'.`); - restoreMocks(); - }); }); From 855e9092a3f5cc008f46abf08ffde6e405167e06 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 19 May 2025 17:09:45 +0800 Subject: [PATCH 08/10] add more testcases --- package.json | 2 +- src/AzureAppConfigurationImpl.ts | 4 +++- test/featureFlag.test.ts | 23 ++++++++++++++++++++++- test/load.test.ts | 21 ++++++++++++++++++++- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index c528c521..26e9a5e8 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "dev": "rollup --config --watch", "lint": "eslint src/ test/", "fix-lint": "eslint src/ test/ --fix", - "test": "mocha out/test/*.test.{js,cjs,mjs} --parallel" + "test": "mocha out/test/featureFlag.test.{js,cjs,mjs} --parallel" }, "repository": { "type": "git", diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 2bf32402..6c6599f0 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -931,7 +931,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 getValidSettingSelectors(selectors); } 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 1d227f7f..ca533d37 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -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); @@ -419,7 +438,7 @@ describe("load", function () { }).to.throw("Invalid separator '%'. Supported values: '.', ',', ';', '-', '_', '__', '/', ':'."); }); - it("should load from snapshot", async () => { + it("should load key values from snapshot", async () => { const snapshotName = "Test"; mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)]); From 030d72d382c831c66dea03aa8d6e93e2b15ccdc8 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 19 May 2025 17:15:05 +0800 Subject: [PATCH 09/10] update --- package.json | 2 +- src/AzureAppConfigurationImpl.ts | 28 +++++++++++++++++++++++++--- src/keyvault/KeyVaultOptions.ts | 8 ++++++++ test/keyvault.test.ts | 12 ++++++++++++ test/load.test.ts | 4 ++-- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 26e9a5e8..c528c521 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "dev": "rollup --config --watch", "lint": "eslint src/ test/", "fix-lint": "eslint src/ test/ --fix", - "test": "mocha out/test/featureFlag.test.{js,cjs,mjs} --parallel" + "test": "mocha out/test/*.test.{js,cjs,mjs} --parallel" }, "repository": { "type": "git", diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 6c6599f0..2f885324 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -10,6 +10,7 @@ import { ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag, + isSecretReference, GetSnapshotOptions, GetSnapshotResponse, KnownSnapshotComposition @@ -102,6 +103,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #ffRefreshTimer: RefreshTimer; + // Key Vault references + #resolveSecretsInParallel: boolean = false; + /** * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors */ @@ -182,6 +186,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } + if (options?.keyVaultOptions?.parallelSecretResolutionEnabled) { + this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled; + } + this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); this.#adapters.push(new JsonKeyValueAdapter()); } @@ -526,7 +534,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); } @@ -536,11 +544,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.#resolveSecretsInParallel && 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) { @@ -585,7 +607,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"); + }); }); diff --git a/test/load.test.ts b/test/load.test.ts index ca533d37..7806789d 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -126,7 +126,7 @@ describe("load", function () { const connectionString = createMockedConnectionString(); return expect(load(connectionString, { selectors: [{ - labelFilter: "\0" + labelFilter: "\0" }] })).eventually.rejectedWith("Key filter cannot be null or empty."); }); @@ -136,7 +136,7 @@ describe("load", function () { return expect(load(connectionString, { selectors: [{ snapshotName: "Test", - labelFilter: "\0" + labelFilter: "\0" }] })).eventually.rejectedWith("Key or label filter should not be used for a snapshot."); }); From da2845de9065a52ed9c20dc58c4674554c51ad4a Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 22 May 2025 11:31:29 +0800 Subject: [PATCH 10/10] update error type --- src/AzureAppConfigurationImpl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 2f885324..fc8759b4 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -497,10 +497,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } else { // snapshot selector const snapshot = await this.#getSnapshot(selector.snapshotName); if (snapshot === undefined) { - throw new Error(`Could not find snapshot with name ${selector.snapshotName}.`); + throw new InvalidOperationError(`Could not find snapshot with name ${selector.snapshotName}.`); } if (snapshot.compositionType != KnownSnapshotComposition.Key) { - throw new Error(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`); + throw new InvalidOperationError(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`); } const pageIterator = listConfigurationSettingsForSnapshotWithTrace( this.#requestTraceOptions,