From 1f41e0d0c6ba7b1a8048bd50a13b5a0e46aec22e Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 19 Dec 2024 16:40:17 +0800 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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 53544afb4f4ed3a069b618925ceba274ea494fe5 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 23 Apr 2025 16:54:20 +0800 Subject: [PATCH 06/19] wip --- src/AzureAppConfigurationImpl.ts | 24 ++++++++++++++++++++++-- src/types.ts | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index f84aeef2..af02733f 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -53,6 +53,8 @@ import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationT import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; +const MAX_TAG_FILTERS = 5; + type PagedSettingSelector = SettingSelector & { /** * Key: page eTag, Value: feature flag configurations @@ -830,8 +832,8 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector return uniqueSelectors.map(selectorCandidate => { const selector = { ...selectorCandidate }; if (selector.snapshotName) { - if (selector.keyFilter || selector.labelFilter) { - throw new Error("Key or label filter should not be used for a snapshot."); + if (selector.keyFilter || selector.labelFilter || selector.tagFilters) { + throw new Error("Key, label or tag filter should not be used for a snapshot."); } } else { if (!selector.keyFilter) { @@ -843,6 +845,9 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { throw new Error("The characters '*' and ',' are not supported in label filters."); } + if (selector.tagFilters) { + validateTagFilters(selector.tagFilters); + } } return selector; }); @@ -867,6 +872,21 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel return getValidSettingSelectors(selectors); } +function validateTagFilters(tagFilters: string[]): void { + if (tagFilters.length > MAX_TAG_FILTERS) { + throw new Error(`The number of tag filters cannot exceed ${MAX_TAG_FILTERS}.`); + } + for (const tagFilter of tagFilters) { + if (!tagFilter.includes("=")) { + throw new Error(`Invalid tag filter: ${tagFilter}. Tag filter must follow the format "tagName=tagValue".`); + } + const [tagName, tagValue] = tagFilter.split("="); + if (tagName === "" || tagValue === "") { + throw new Error(`Invalid tag filter: ${tagFilter}. Tag name and value cannot be empty.`); + } + } +} + function isFailoverableError(error: any): boolean { // ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory return isRestError(error) && (error.code === "ENOTFOUND" || error.code === "ENOENT" || diff --git a/src/types.ts b/src/types.ts index 8ba89257..00a2d794 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,16 @@ export type SettingSelector = { */ labelFilter?: string + /** + * The tag filter to apply when querying Azure App Configuration for key-values. + * + * @remarks + * Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. + * Built in tag filter value is `TagFilter.Null`, which indicates the tag has no value. For example, $"tagName={TagFilter.Null}" will match all key-values with the tag "tagName" that has no value. + * Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. + */ + tagFilters?: string[] + /** * The name of snapshot to load from App Configuration. * @@ -59,3 +69,13 @@ export enum LabelFilter { */ Null = "\0" } + +/** + * TagFilter is used to filter key-values based on tags. + */ +export enum TagFilter { + /** + * Matches key-values without a label. + */ + Null = "\0" +} From 64f76046aa3fc57d46f68bd9b42fe85712033666 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 23 Apr 2025 17:25:05 +0800 Subject: [PATCH 07/19] wip --- package-lock.json | 32 +++++++++++++++++++++++++++----- package.json | 2 +- src/AzureAppConfigurationImpl.ts | 4 +++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index b4979eed..f36ea0a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.0.2", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.6.1", + "@azure/app-configuration": "^1.8.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0" }, @@ -57,11 +57,11 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.6.1.tgz", - "integrity": "sha512-pk8zyG/8Nc6VN7uDA9QY19UFhTXneUbnB+5IcW9uuPyVDXU17TcXBI4xY1ZBm7hmhn0yh3CeZK4kOxa/tjsMqQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.9.0.tgz", + "integrity": "sha512-X0AVDQygL4AGLtplLYW+W0QakJpJ417sQldOacqwcBQ882tAPdUVs6V3mZ4jUjwVsgr+dV1v9zMmijvsp6XBxA==", "dependencies": { - "@azure/abort-controller": "^1.0.0", + "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", "@azure/core-http-compat": "^2.0.0", @@ -77,6 +77,17 @@ "node": ">=18.0.0" } }, + "node_modules/@azure/app-configuration/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@azure/app-configuration/node_modules/@azure/core-http-compat": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.0.1.tgz", @@ -90,6 +101,17 @@ "node": ">=14.0.0" } }, + "node_modules/@azure/app-configuration/node_modules/@azure/core-http-compat/node_modules/@azure/abort-controller": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", + "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@azure/core-auth": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.5.0.tgz", diff --git a/package.json b/package.json index c528c521..08374014 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "uuid": "^9.0.1" }, "dependencies": { - "@azure/app-configuration": "^1.6.1", + "@azure/app-configuration": "^1.8.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0" } diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index af02733f..c001246c 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -402,7 +402,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (selector.snapshotName === undefined) { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter + labelFilter: selector.labelFilter, + tagsFilter: selector.tagFilters }; const pageEtags: string[] = []; const pageIterator = listConfigurationSettingsWithTrace( @@ -605,6 +606,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, + tagsFilter: selector.tagFilters, pageEtags: selector.pageEtags }; From 5fc57b0b784626cdffcfda4281f5dd2fac7a46c8 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 23 Apr 2025 18:07:47 +0800 Subject: [PATCH 08/19] support tag filter --- src/AzureAppConfigurationImpl.ts | 27 ++++++++++++++++++++++++--- src/types.ts | 4 ++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index c001246c..475b6e93 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -824,7 +824,11 @@ 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 && s.snapshotName === selector.snapshotName); + const existingSelectorIndex = uniqueSelectors.findIndex( + s => s.keyFilter === selector.keyFilter && + s.labelFilter === selector.labelFilter && + s.snapshotName === selector.snapshotName && + areTagFiltersEqual(s.tagFilters, selector.tagFilters)); if (existingSelectorIndex >= 0) { uniqueSelectors.splice(existingSelectorIndex, 1); } @@ -855,6 +859,23 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector }); } +function areTagFiltersEqual(tagsA?: string[], tagsB?: string[]): boolean { + if (!tagsA && !tagsB) { + return true; + } + if (!tagsA || !tagsB) { + return false; + } + if (tagsA.length !== tagsB.length) { + return false; + } + + const sortedStringA = [...tagsA].sort().join('\n'); + const sortedStringB = [...tagsB].sort().join('\n'); + + return sortedStringA === sortedStringB; +} + function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] { if (selectors === undefined || selectors.length === 0) { // Default selector: key: *, label: \0 @@ -883,8 +904,8 @@ function validateTagFilters(tagFilters: string[]): void { throw new Error(`Invalid tag filter: ${tagFilter}. Tag filter must follow the format "tagName=tagValue".`); } const [tagName, tagValue] = tagFilter.split("="); - if (tagName === "" || tagValue === "") { - throw new Error(`Invalid tag filter: ${tagFilter}. Tag name and value cannot be empty.`); + if (tagName === "") { + throw new Error(`Invalid tag filter: ${tagFilter}. Tag name cannot be empty.`); } } } diff --git a/src/types.ts b/src/types.ts index 00a2d794..e17a53d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,7 +35,7 @@ export type SettingSelector = { * * @remarks * Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. - * Built in tag filter value is `TagFilter.Null`, which indicates the tag has no value. For example, $"tagName={TagFilter.Null}" will match all key-values with the tag "tagName" that has no value. + * Built in tag filter value is `TagFilter.Null`, which indicates the tag has no value. For example, `tagName=${TagFilter.Null}` will match all key-values with the tag "tagName" that has no value. * Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. */ tagFilters?: string[] @@ -77,5 +77,5 @@ export enum TagFilter { /** * Matches key-values without a label. */ - Null = "\0" + Null = "" } From 1f5d9d8a7f3f7a9da82a0f9429b7fec54822d6de Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 23 Apr 2025 18:56:43 +0800 Subject: [PATCH 09/19] add test --- src/AzureAppConfigurationImpl.ts | 17 ++++------ test/load.test.ts | 55 ++++++++++++++++++++++++++++++-- test/utils/testHelper.ts | 13 ++++++-- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 475b6e93..8837a2c4 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -842,7 +842,7 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector throw new Error("Key, label or tag filter should not be used for a snapshot."); } } else { - if (!selector.keyFilter) { + if (!selector.keyFilter && (!selector.tagFilters || selector.tagFilters.length === 0)) { throw new Error("Key filter cannot be null or empty."); } if (!selector.labelFilter) { @@ -869,10 +869,10 @@ function areTagFiltersEqual(tagsA?: string[], tagsB?: string[]): boolean { if (tagsA.length !== tagsB.length) { return false; } - - const sortedStringA = [...tagsA].sort().join('\n'); - const sortedStringB = [...tagsB].sort().join('\n'); - + + const sortedStringA = [...tagsA].sort().join("\n"); + const sortedStringB = [...tagsB].sort().join("\n"); + return sortedStringA === sortedStringB; } @@ -900,13 +900,10 @@ function validateTagFilters(tagFilters: string[]): void { throw new Error(`The number of tag filters cannot exceed ${MAX_TAG_FILTERS}.`); } for (const tagFilter of tagFilters) { - if (!tagFilter.includes("=")) { + const res = tagFilter.split("="); + if (res[0] === "" || res.length !== 2) { throw new Error(`Invalid tag filter: ${tagFilter}. Tag filter must follow the format "tagName=tagValue".`); } - const [tagName, tagValue] = tagFilter.split("="); - if (tagName === "") { - throw new Error(`Invalid tag filter: ${tagFilter}. Tag name cannot be empty.`); - } } } diff --git a/test/load.test.ts b/test/load.test.ts index b0f39985..efcb50f1 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -29,10 +29,12 @@ const mockedKVs = [{ }, { key: "TestKey", label: "Test", + tags: {"testTag": ""}, value: "TestValue", }, { key: "TestKey", label: "Prod", + tags: {"testTag": ""}, value: "TestValueForProd", }, { key: "KeyForNullValue", @@ -73,6 +75,18 @@ const mockedKVs = [{ } }), contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" +}, { + key: "keyWithMultipleTags", + value: "someValue", + tags: {"tag1": "someValue", "tag2": "someValue"} +}, { + key: "keyWithTag1", + value: "someValue", + tags: {"tag1": "someValue"} +}, { + key: "keyWithTag2", + value: "someValue", + tags: {"tag2": "someValue"} } ].map(createMockedKeyValue); @@ -146,6 +160,30 @@ describe("load", function () { expect(settings.get("app.settings.fontFamily")).undefined; }); + it("should filter by tags, has(key) and get(key) should work", async () => { + const connectionString = createMockedConnectionString(); + const loadWithTag1 = await load(connectionString, { + selectors: [{ + tagFilters: ["tag1=someValue"] + }] + }); + expect(loadWithTag1.has("keyWithTag1")).true; + expect(loadWithTag1.get("keyWithTag1")).eq("someValue"); + expect(loadWithTag1.has("keyWithTag2")).false; + expect(loadWithTag1.has("keyWithMultipleTags")).true; + expect(loadWithTag1.get("keyWithMultipleTags")).eq("someValue"); + + const loadWithMultipleTags = await load(connectionString, { + selectors: [{ + tagFilters: ["tag1=someValue", "tag2=someValue"] + }] + }); + expect(loadWithMultipleTags.has("keyWithTag1")).false; + expect(loadWithMultipleTags.has("keyWithTag2")).false; + expect(loadWithMultipleTags.has("keyWithMultipleTags")).true; + expect(loadWithMultipleTags.get("keyWithMultipleTags")).eq("someValue"); + }); + it("should also work with other ReadonlyMap APIs", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -255,6 +293,17 @@ describe("load", function () { return expect(loadWithMultipleLabelFilter).to.eventually.rejectedWith("The characters '*' and ',' are not supported in label filters."); }); + it("should throw exception when there is any invalid tag filter", async () => { + const connectionString = createMockedConnectionString(); + const loadWithInvalidTagFilter = load(connectionString, { + selectors: [{ + tagFilters: ["testTag"] + }] + }); + return expect(loadWithInvalidTagFilter).to.eventually.rejectedWith("Tag filter must follow the format \"tagName=tagValue\""); + }); + + it("should override config settings with same key but different label", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -275,13 +324,15 @@ describe("load", function () { const settings = await load(connectionString, { selectors: [{ keyFilter: "Test*", - labelFilter: "Prod" + labelFilter: "Prod", + tagFilters: ["testTag="] }, { keyFilter: "Test*", labelFilter: "Test" }, { keyFilter: "Test*", - labelFilter: "Prod" + labelFilter: "Prod", + tagFilters: ["testTag="] }] }); expect(settings).not.undefined; diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 30ecd129..7eccd442 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -26,6 +26,7 @@ function _sha256(input) { function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { const keyFilter = listOptions?.keyFilter ?? "*"; const labelFilter = listOptions?.labelFilter ?? "*"; + const tagsFilter = listOptions?.tagsFilter ?? []; return unfilteredKvs.filter(kv => { const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter; let labelMatched = false; @@ -38,7 +39,14 @@ function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { } else { labelMatched = kv.label === labelFilter; } - return keyMatched && labelMatched; + let tagsMatched = true; + if (tagsFilter.length > 0) { + tagsMatched = tagsFilter.every(tag => { + const [tagName, tagValue] = tag.split("="); + return kv.tags && kv.tags[tagName] === tagValue; + }); + } + return keyMatched && labelMatched && tagsMatched; }); } @@ -233,8 +241,7 @@ const createMockedKeyVaultReference = (key: string, vaultUri: string): Configura key, contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", lastModified: new Date(), - tags: { - }, + tags: {}, etag: uuid.v4(), isReadOnly: false, }); From 994c10ec784bfa64f80eb9adacc4f931921d6ea6 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 23 Apr 2025 19:09:37 +0800 Subject: [PATCH 10/19] 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 11/19] 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 ca3c113c2f6323000449e90b674cff53a1c90456 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 19 May 2025 13:53:19 +0800 Subject: [PATCH 12/19] update --- src/AzureAppConfigurationImpl.ts | 8 +- src/types.ts | 2 +- test/load.test.ts | 440 ------------------------------- 3 files changed, 5 insertions(+), 445 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index ac5720a9..1f170d04 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -58,7 +58,7 @@ import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds -const MAX_TAG_FILTERS = 5; +const MAX_TAG_FILTER_COUNT = 5; type PagedSettingSelector = SettingSelector & { /** @@ -912,7 +912,7 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector } } else { if (!selector.keyFilter && (!selector.tagFilters || selector.tagFilters.length === 0)) { - throw new ArgumentError("Key filter cannot be null or empty."); + throw new ArgumentError("Key filter and tag filter cannot both be null or empty."); } if (!selector.labelFilter) { selector.labelFilter = LabelFilter.Null; @@ -965,8 +965,8 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel } function validateTagFilters(tagFilters: string[]): void { - if (tagFilters.length > MAX_TAG_FILTERS) { - throw new Error(`The number of tag filters cannot exceed ${MAX_TAG_FILTERS}.`); + if (tagFilters.length > MAX_TAG_FILTER_COUNT) { + throw new Error(`The number of tag filters cannot exceed ${MAX_TAG_FILTER_COUNT}.`); } for (const tagFilter of tagFilters) { const res = tagFilter.split("="); diff --git a/src/types.ts b/src/types.ts index c2eb5663..21ce23f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,7 +75,7 @@ export enum LabelFilter { */ export enum TagFilter { /** - * Matches key-values without a label. + * Represents empty tag value. */ Null = "" } diff --git a/test/load.test.ts b/test/load.test.ts index c7afe3d6..a1ee7a00 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -1,4 +1,3 @@ -<<<<<<< HEAD // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. @@ -487,442 +486,3 @@ describe("load", function () { restoreMocks(); }); }); -======= -// 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, mockAppConfigurationClientGetSnapshot, mockAppConfigurationClientListConfigurationSettingsForSnapshot, 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: '.', ',', ';', '-', '_', '__', '/', ':'."); - }); - - 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"); - restoreMocks(); - }); -}); ->>>>>>> 667f721c09b29017032cd378073ee20bb2252471 From 855e9092a3f5cc008f46abf08ffde6e405167e06 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 19 May 2025 17:09:45 +0800 Subject: [PATCH 13/19] 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 14/19] 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 7a246d917e89211323719125d0e42bfd1ce32b7e Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 22 May 2025 12:54:43 +0800 Subject: [PATCH 15/19] 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 a02d4fac..b89c1f4d 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -322,7 +322,6 @@ describe("load", function () { return expect(loadWithInvalidTagFilter).to.eventually.rejectedWith("Tag filter must follow the format \"tagName=tagValue\""); }); - it("should override config settings with same key but different label", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { From 58ff6c3e745a4d312e3c1da3f2ec3682b6cc12ed Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 22 May 2025 13:01:37 +0800 Subject: [PATCH 16/19] update testcase --- 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 b89c1f4d..a2af6e5a 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -142,7 +142,7 @@ describe("load", function () { selectors: [{ labelFilter: "\0" }] - })).eventually.rejectedWith("Key filter cannot be null or empty."); + })).eventually.rejectedWith("Key filter and tag filter cannot both be null or empty."); }); it("should throw error given invalid snapshot selector", async () => { @@ -152,7 +152,7 @@ describe("load", function () { snapshotName: "Test", labelFilter: "\0" }] - })).eventually.rejectedWith("Key or label filter should not be used for a snapshot."); + })).eventually.rejectedWith("Key, label or tag filter should not be used for a snapshot."); }); it("should not include feature flags directly in the settings", async () => { From fe98b43d5294cda1a6c583e0c396b91e820a4c99 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Fri, 1 Aug 2025 17:22:30 +0800 Subject: [PATCH 17/19] update --- package-lock.json | 20 +++++++++++--------- package.json | 2 +- src/AzureAppConfigurationImpl.ts | 11 +++-------- test/load.test.ts | 7 +++++-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2582372..96642c4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1251,10 +1251,11 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2816,9 +2817,9 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3244,10 +3245,11 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } diff --git a/package.json b/package.json index 20453a04..a3aade39 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/load.test.{js,cjs,mjs} --parallel" }, "repository": { "type": "git", diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 5c3c732e..f96d639d 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -60,8 +60,6 @@ import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds -const MAX_TAG_FILTER_COUNT = 5; - type PagedSettingSelector = SettingSelector & { pageEtags?: string[]; }; @@ -982,11 +980,11 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector const selector = { ...selectorCandidate }; if (selector.snapshotName) { if (selector.keyFilter || selector.labelFilter || selector.tagFilters) { - throw new ArgumentError("Key, label or tag filter should not be used for a snapshot."); + throw new ArgumentError("Key, label or tag filters should not be specified while selecting a snapshot."); } } else { - if (!selector.keyFilter && (!selector.tagFilters || selector.tagFilters.length === 0)) { - throw new ArgumentError("Key filter and tag filter cannot both be null or empty."); + if (!selector.keyFilter) { + throw new ArgumentError("Key filter cannot be null or empty."); } if (!selector.labelFilter) { selector.labelFilter = LabelFilter.Null; @@ -1041,9 +1039,6 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel } function validateTagFilters(tagFilters: string[]): void { - if (tagFilters.length > MAX_TAG_FILTER_COUNT) { - throw new Error(`The number of tag filters cannot exceed ${MAX_TAG_FILTER_COUNT}.`); - } for (const tagFilter of tagFilters) { const res = tagFilter.split("="); if (res[0] === "" || res.length !== 2) { diff --git a/test/load.test.ts b/test/load.test.ts index a2af6e5a..31bbb3e6 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -142,7 +142,7 @@ describe("load", function () { selectors: [{ labelFilter: "\0" }] - })).eventually.rejectedWith("Key filter and tag filter cannot both be null or empty."); + })).eventually.rejectedWith("Key filter cannot be null or empty."); }); it("should throw error given invalid snapshot selector", async () => { @@ -152,7 +152,7 @@ describe("load", function () { snapshotName: "Test", labelFilter: "\0" }] - })).eventually.rejectedWith("Key, label or tag filter should not be used for a snapshot."); + })).eventually.rejectedWith("Key, label or tag filters should not be specified while selecting a snapshot."); }); it("should not include feature flags directly in the settings", async () => { @@ -183,6 +183,7 @@ describe("load", function () { const connectionString = createMockedConnectionString(); const loadWithTag1 = await load(connectionString, { selectors: [{ + keyFilter: "*", tagFilters: ["tag1=someValue"] }] }); @@ -194,6 +195,7 @@ describe("load", function () { const loadWithMultipleTags = await load(connectionString, { selectors: [{ + keyFilter: "*", tagFilters: ["tag1=someValue", "tag2=someValue"] }] }); @@ -316,6 +318,7 @@ describe("load", function () { const connectionString = createMockedConnectionString(); const loadWithInvalidTagFilter = load(connectionString, { selectors: [{ + keyFilter: "*", tagFilters: ["testTag"] }] }); From b373d1ef184665de339e25b454e87d68820591f7 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 4 Aug 2025 16:45:22 +0800 Subject: [PATCH 18/19] add more testcases --- package.json | 2 +- src/AzureAppConfigurationImpl.ts | 3 + test/featureFlag.test.ts | 75 +++++++++++++++++++++++++ test/load.test.ts | 95 ++++++++++++++++++++++++++++++-- test/refresh.test.ts | 77 +++++++++++++++++++++++++- test/utils/testHelper.ts | 5 ++ 6 files changed, 251 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a3aade39..20453a04 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/load.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 f96d639d..011c2017 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -395,6 +395,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (isInputError(error)) { throw error; } + if (isRestError(error) && !isFailoverableError(error)) { + throw error; + } if (abortSignal.aborted) { return; } diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 14586cf7..3c42007c 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -55,6 +55,9 @@ const mockedKVs = [{ createMockedFeatureFlag("FlagWithTestLabel", { enabled: true }, {label: "Test"}), createMockedFeatureFlag("Alpha_1", { enabled: true }), createMockedFeatureFlag("Alpha_2", { enabled: false }), + createMockedFeatureFlag("DevFeatureFlag", { enabled: true }, { tags: { "environment": "dev" } }), + createMockedFeatureFlag("ProdFeatureFlag", { enabled: false }, { tags: { "environment": "prod" } }), + createMockedFeatureFlag("TaggedFeature", { enabled: true }, { tags: { "team": "backend", "priority": "high" } }), createMockedFeatureFlag("Telemetry_1", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag"}), createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag", label: "Test"}), createMockedFeatureFlag("NoPercentileAndSeed", { @@ -338,6 +341,78 @@ describe("feature flags", function () { expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); }); + it("should load feature flags using tag filters", async () => { + const connectionString = createMockedConnectionString(); + + // Test filtering by environment=dev tag + const settingsWithDevTag = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["environment=dev"] + }] + } + }); + + expect(settingsWithDevTag).not.undefined; + expect(settingsWithDevTag.get("feature_management")).not.undefined; + let featureFlags = settingsWithDevTag.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + expect(featureFlags[0].id).equals("DevFeatureFlag"); + expect(featureFlags[0].enabled).equals(true); + + // Test filtering by environment=prod tag + const settingsWithProdTag = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["environment=prod"] + }] + } + }); + + featureFlags = settingsWithProdTag.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + expect(featureFlags[0].id).equals("ProdFeatureFlag"); + expect(featureFlags[0].enabled).equals(false); + + // Test filtering by multiple tags (team=backend AND priority=high) + const settingsWithMultipleTags = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["team=backend", "priority=high"] + }] + } + }); + + featureFlags = settingsWithMultipleTags.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + expect(featureFlags[0].id).equals("TaggedFeature"); + expect(featureFlags[0].enabled).equals(true); + + // Test filtering by non-existent tag + const settingsWithNonExistentTag = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["nonexistent=tag"] + }] + } + }); + + featureFlags = settingsWithNonExistentTag.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(0); + }); + it("should load feature flags from snapshot", async () => { const snapshotName = "Test"; mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); diff --git a/test/load.test.ts b/test/load.test.ts index 31bbb3e6..b723f2b4 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -81,12 +81,24 @@ const mockedKVs = [{ tags: {"tag1": "someValue", "tag2": "someValue"} }, { key: "keyWithTag1", - value: "someValue", + value: "someValue1", tags: {"tag1": "someValue"} }, { key: "keyWithTag2", - value: "someValue", + value: "someValue2", tags: {"tag2": "someValue"} +}, { + key: "keyWithNullTag", + value: "valueWithNullTag", + tags: {"nullTag": "\0"} +}, { + key: "keyWithEscapedComma", + value: "valueWithEscapedComma", + tags: {"tag": "value\\,with\\,commas"} +}, { + key: "keyWithEmptyTag", + value: "valueWithEmptyTag", + tags: {"emptyTag": ""} } ].map(createMockedKeyValue); @@ -188,7 +200,7 @@ describe("load", function () { }] }); expect(loadWithTag1.has("keyWithTag1")).true; - expect(loadWithTag1.get("keyWithTag1")).eq("someValue"); + expect(loadWithTag1.get("keyWithTag1")).eq("someValue1"); expect(loadWithTag1.has("keyWithTag2")).false; expect(loadWithTag1.has("keyWithMultipleTags")).true; expect(loadWithTag1.get("keyWithMultipleTags")).eq("someValue"); @@ -205,6 +217,53 @@ describe("load", function () { expect(loadWithMultipleTags.get("keyWithMultipleTags")).eq("someValue"); }); + it("should filter by nullTag to load key values with null tag", async () => { + const connectionString = createMockedConnectionString(); + const loadWithNullTag = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["nullTag=\0"] + }] + }); + + // Should include only key values with nullTag=\0 + expect(loadWithNullTag.has("keyWithNullTag")).true; + expect(loadWithNullTag.get("keyWithNullTag")).eq("valueWithNullTag"); + + // Should exclude key values with other tags + expect(loadWithNullTag.has("keyWithEmptyTag")).false; + }); + + it("should filter by tags with escaped comma characters", async () => { + const connectionString = createMockedConnectionString(); + const loadWithEscapedComma = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["tag=value\\,with\\,commas"] + }] + }); + + expect(loadWithEscapedComma.has("keyWithEscapedComma")).true; + expect(loadWithEscapedComma.get("keyWithEscapedComma")).eq("valueWithEscapedComma"); + }); + + it("should filter by empty tag value to load key values with empty tag", async () => { + const connectionString = createMockedConnectionString(); + const loadWithEmptyTag = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["emptyTag="] + }] + }); + + // Should include key values with emptyTag="" + expect(loadWithEmptyTag.has("keyWithEmptyTag")).true; + expect(loadWithEmptyTag.get("keyWithEmptyTag")).eq("valueWithEmptyTag"); + + // Should exclude key values with other tags + expect(loadWithEmptyTag.has("keyWithNullTag")).false; + }); + it("should also work with other ReadonlyMap APIs", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -319,12 +378,40 @@ describe("load", function () { const loadWithInvalidTagFilter = load(connectionString, { selectors: [{ keyFilter: "*", - tagFilters: ["testTag"] + tagFilters: ["emptyTag"] }] }); return expect(loadWithInvalidTagFilter).to.eventually.rejectedWith("Tag filter must follow the format \"tagName=tagValue\""); }); + it("should throw exception when too many tag filters are provided", async () => { + const connectionString = createMockedConnectionString(); + + // Create a list with more than the maximum allowed tag filters (assuming max is 5) + const tooManyTagFilters = [ + "Environment=Development", + "Team=Backend", + "Priority=High", + "Version=1.0", + "Stage=Testing", + "Region=EastUS" // This should exceed the limit + ]; + try { + await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: tooManyTagFilters + }] + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Invalid request parameter 'tags'. Maximum number of tag filters is 5."); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); + it("should override config settings with same key but different label", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 704d6c21..0468475d 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -39,7 +39,8 @@ describe("dynamic refresh", function () { mockedKVs = [ { value: "red", key: "app.settings.fontColor" }, { value: "40", key: "app.settings.fontSize" }, - { value: "30", key: "app.settings.fontSize", label: "prod" } + { value: "30", key: "app.settings.fontSize", label: "prod" }, + { value: "someValue", key: "TestTagKey", tags: { "env": "dev" } } ].map(createMockedKeyValue); mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvCallback); mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback); @@ -435,6 +436,34 @@ describe("dynamic refresh", function () { expect(getKvRequestCount).eq(1); expect(settings.get("app.settings.fontColor")).eq("blue"); }); + + it("should refresh key values using tag filters", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["env=dev"] + }], + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + + expect(settings).not.undefined; + + // Verify only dev-tagged items are loaded + expect(settings.get("TestTagKey")).eq("someValue"); + + // Change the dev-tagged key value + updateSetting("TestTagKey", "newValue"); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + + // Verify changes are reflected + expect(settings.get("TestTagKey")).eq("newValue"); + }); }); describe("dynamic refresh feature flags", function () { @@ -549,4 +578,50 @@ describe("dynamic refresh feature flags", function () { expect(getKvRequestCount).eq(0); expect(refreshSuccessfulCount).eq(1); // change in feature flags, because page etags are different. }); + + it("should refresh feature flags using tag filters", async () => { + mockedKVs = [ + createMockedFeatureFlag("DevFeature", { enabled: true }, { tags: { "env": "dev" } }), + createMockedFeatureFlag("ProdFeature", { enabled: false }, { tags: { "env": "prod" } }) + ]; + mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["env=dev"] + }], + refresh: { + enabled: true, + refreshIntervalInMs: 2000 + } + } + }); + + expect(settings).not.undefined; + + const featureManagement = settings.get("feature_management"); + expect(featureManagement).not.undefined; + expect(featureManagement.feature_flags).not.undefined; + expect(featureManagement.feature_flags.length).eq(1); + expect(featureManagement.feature_flags[0].id).eq("DevFeature"); + expect(featureManagement.feature_flags[0].enabled).eq(true); + + // Change the dev-tagged feature flag + updateSetting(".appconfig.featureflag/DevFeature", JSON.stringify({ + "id": "DevFeature", + "enabled": false + })); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + + const updatedFeatureManagement = settings.get("feature_management"); + expect(updatedFeatureManagement.feature_flags[0].id).eq("DevFeature"); + expect(updatedFeatureManagement.feature_flags[0].enabled).eq(false); + }); }); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 37ce53e7..5f6095a4 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -27,6 +27,11 @@ function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { const keyFilter = listOptions?.keyFilter ?? "*"; const labelFilter = listOptions?.labelFilter ?? "*"; const tagsFilter = listOptions?.tagsFilter ?? []; + + if (tagsFilter.length > 5) { + throw new RestError("Invalid request parameter 'tags'. Maximum number of tag filters is 5.", { statusCode: 400 }); + } + return unfilteredKvs.filter(kv => { const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter; let labelMatched = false; From 505c54239eea66822f5073219cd1f0095e4963e4 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 5 Aug 2025 11:28:34 +0800 Subject: [PATCH 19/19] correct null tag test --- test/load.test.ts | 2 +- test/utils/testHelper.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/test/load.test.ts b/test/load.test.ts index b723f2b4..244782e9 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -90,7 +90,7 @@ const mockedKVs = [{ }, { key: "keyWithNullTag", value: "valueWithNullTag", - tags: {"nullTag": "\0"} + tags: {"nullTag": null} }, { key: "keyWithEscapedComma", value: "valueWithEscapedComma", diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 5f6095a4..de0d2470 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -48,6 +48,9 @@ function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { if (tagsFilter.length > 0) { tagsMatched = tagsFilter.every(tag => { const [tagName, tagValue] = tag.split("="); + if (tagValue === "\0") { + return kv.tags && kv.tags[tagName] === null; + } return kv.tags && kv.tags[tagName] === tagValue; }); }