From 4e76094332c1d2661557d506d40df8e17fb65620 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:15:21 +0800 Subject: [PATCH 1/5] Merge pull request #203 from Azure/dependabot/npm_and_yarn/form-data-4.0.4 Bump form-data from 4.0.0 to 4.0.4 --- package-lock.json | 187 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 550e7f3d..6bd8f99f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1261,6 +1261,19 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1548,6 +1561,20 @@ "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1568,6 +1595,51 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -1900,12 +1972,15 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -1932,6 +2007,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1950,6 +2034,43 @@ "node": "*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -2017,6 +2138,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2041,6 +2174,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -2513,6 +2685,15 @@ "node": ">=12" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", From 30e4d8324e2dcd29cb307b1ef5f19b652303e0a4 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Mon, 4 Aug 2025 17:01:07 +0800 Subject: [PATCH 2/5] fix startup retry bug (#206) --- src/AzureAppConfigurationImpl.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 693cec90..7c1263cd 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; } From c6acf1800125b316dd9f92a7e361ba7b78845774 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:55:03 +0800 Subject: [PATCH 3/5] Support comment in json key value (#205) * support comment in json key value * update testcase * strip comment only when normal parse failed --- package-lock.json | 27 +++++++----- package.json | 3 +- rollup.config.mjs | 1 + src/JsonKeyValueAdapter.ts | 30 +++++++++++--- test/json.test.ts | 85 +++++++++++++++++++++++++++++++++++++- 5 files changed, 127 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6bd8f99f..9aa77a9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@azure/app-configuration": "^1.6.1", "@azure/identity": "^4.2.1", - "@azure/keyvault-secrets": "^4.7.0" + "@azure/keyvault-secrets": "^4.7.0", + "jsonc-parser": "^3.3.1" }, "devDependencies": { "@rollup/plugin-typescript": "^11.1.2", @@ -1229,9 +1230,9 @@ } }, "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, "dependencies": { "balanced-match": "^1.0.0", @@ -2488,6 +2489,11 @@ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "dev": true }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -2794,11 +2800,10 @@ } }, "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": { "balanced-match": "^1.0.0" } @@ -3222,9 +3227,9 @@ } }, "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, "dependencies": { "balanced-match": "^1.0.0" diff --git a/package.json b/package.json index 4ac941b2..57e76127 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "dependencies": { "@azure/app-configuration": "^1.6.1", "@azure/identity": "^4.2.1", - "@azure/keyvault-secrets": "^4.7.0" + "@azure/keyvault-secrets": "^4.7.0", + "jsonc-parser": "^3.3.1" } } diff --git a/rollup.config.mjs b/rollup.config.mjs index 16224a2f..0df0e168 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -11,6 +11,7 @@ export default [ "@azure/identity", "crypto", "dns/promises", + "jsonc-parser", "@microsoft/feature-management" ], input: "src/index.ts", diff --git a/src/JsonKeyValueAdapter.ts b/src/JsonKeyValueAdapter.ts index 84266069..75ecaffc 100644 --- a/src/JsonKeyValueAdapter.ts +++ b/src/JsonKeyValueAdapter.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { ConfigurationSetting, featureFlagContentType, secretReferenceContentType } from "@azure/app-configuration"; +import { stripComments } from "jsonc-parser"; import { parseContentType, isJsonContentType } from "./common/contentType.js"; import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; @@ -25,13 +26,19 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter { async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { let parsedValue: unknown; if (setting.value !== undefined) { - try { - parsedValue = JSON.parse(setting.value); - } catch (error) { - parsedValue = setting.value; + const parseResult = this.#tryParseJson(setting.value); + if (parseResult.success) { + parsedValue = parseResult.result; + } else { + // Try parsing with comments stripped + const parseWithoutCommentsResult = this.#tryParseJson(stripComments(setting.value)); + if (parseWithoutCommentsResult.success) { + parsedValue = parseWithoutCommentsResult.result; + } else { + // If still not valid JSON, return the original value + parsedValue = setting.value; + } } - } else { - parsedValue = setting.value; } return [setting.key, parsedValue]; } @@ -39,4 +46,15 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter { async onChangeDetected(): Promise { return; } + + #tryParseJson(value: string): { success: true; result: unknown } | { success: false } { + try { + return { success: true, result: JSON.parse(value) }; + } catch (error) { + if (error instanceof SyntaxError) { + return { success: false }; + } + throw error; + } + } } diff --git a/test/json.test.ts b/test/json.test.ts index a2b57907..fbb5f03e 100644 --- a/test/json.test.ts +++ b/test/json.test.ts @@ -60,7 +60,7 @@ describe("json", function () { createMockedJsonKeyValue("json.settings.object", "{}"), createMockedJsonKeyValue("json.settings.array", "[]"), createMockedJsonKeyValue("json.settings.number", "8"), - createMockedJsonKeyValue("json.settings.string", "string"), + createMockedJsonKeyValue("json.settings.string", "\"string\""), createMockedJsonKeyValue("json.settings.false", "false"), createMockedJsonKeyValue("json.settings.true", "true"), createMockedJsonKeyValue("json.settings.null", "null"), @@ -88,4 +88,87 @@ describe("json", function () { expect(settings.get("json.settings.emptyString")).eq("", "is empty string"); expect(settings.get("json.settings.illegalString")).eq("[unclosed", "is illegal string"); }); + + it("should load json values with comments", async () => { + // Test various comment styles and positions + const mixedCommentStylesValue = `{ + // Single line comment at start + "ApiSettings": { + "BaseUrl": "https://api.example.com", // Inline single line + /* Multi-line comment + spanning multiple lines */ + "ApiKey": "secret-key", + "Endpoints": [ + // Comment before array element + "/users", + /* Comment between elements */ + "/orders", + "/products" // Comment after element + ] + }, + // Test edge cases + "StringWithSlashes": "This is not a // comment", + "StringWithStars": "This is not a /* comment */", + "UrlValue": "https://example.com/path", // This is a real comment + "EmptyComment": "value", // + /**/ + "AfterEmptyComment": "value2" + /* Final multi-line comment */ + }`; + + // Test invalid JSON with comments + const invalidJsonWithCommentsValue = `// This is a comment + { invalid json structure + // Another comment + missing quotes and braces`; + + // Test only comments (should be invalid JSON) + const onlyCommentsValue = ` + // Just comments + /* No actual content */ + `; + + const keyValues = [ + createMockedJsonKeyValue("MixedCommentStyles", mixedCommentStylesValue), + createMockedJsonKeyValue("InvalidJsonWithComments", invalidJsonWithCommentsValue), + createMockedJsonKeyValue("OnlyComments", onlyCommentsValue) + ]; + + mockAppConfigurationClientListConfigurationSettings([keyValues]); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + expect(settings).not.undefined; + + // Verify mixed comment styles are properly parsed + const mixedConfig = settings.get("MixedCommentStyles"); + expect(mixedConfig).not.undefined; + expect(mixedConfig.ApiSettings).not.undefined; + expect(mixedConfig.ApiSettings.BaseUrl).eq("https://api.example.com"); + expect(mixedConfig.ApiSettings.ApiKey).eq("secret-key"); + expect(mixedConfig.ApiSettings.Endpoints).not.undefined; + expect(Array.isArray(mixedConfig.ApiSettings.Endpoints)).eq(true); + expect(mixedConfig.ApiSettings.Endpoints[0]).eq("/users"); + expect(mixedConfig.ApiSettings.Endpoints[1]).eq("/orders"); + expect(mixedConfig.ApiSettings.Endpoints[2]).eq("/products"); + + // Verify edge cases where comment-like text appears in strings + expect(mixedConfig.StringWithSlashes).eq("This is not a // comment"); + expect(mixedConfig.StringWithStars).eq("This is not a /* comment */"); + expect(mixedConfig.UrlValue).eq("https://example.com/path"); + expect(mixedConfig.EmptyComment).eq("value"); + expect(mixedConfig.AfterEmptyComment).eq("value2"); + + // Invalid JSON should fall back to string value + const invalidConfig = settings.get("InvalidJsonWithComments"); + expect(invalidConfig).not.undefined; + expect(typeof invalidConfig).eq("string"); + expect(invalidConfig).eq(invalidJsonWithCommentsValue); + + // Only comments should be treated as string value (invalid JSON) + const onlyCommentsConfig = settings.get("OnlyComments"); + expect(onlyCommentsConfig).not.undefined; + expect(typeof onlyCommentsConfig).eq("string"); + expect(onlyCommentsConfig).eq(onlyCommentsValue); + }); }); From e36e0670f5f7d5ea4e73301c9f39b96165d086e1 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:17:27 +0800 Subject: [PATCH 4/5] Support tag filter (#188) * support snapshot * add testcase * add testcase * fix lint * update * wip * wip * support tag filter * add test * update test * update testcase * update * add more testcases * update * fix lint * update testcase * update * add more testcases * correct null tag test --- package-lock.json | 34 +++++-- package.json | 2 +- src/AzureAppConfigurationImpl.ts | 43 ++++++++- src/types.ts | 20 +++++ test/featureFlag.test.ts | 75 ++++++++++++++++ test/load.test.ts | 146 ++++++++++++++++++++++++++++++- test/refresh.test.ts | 77 +++++++++++++++- test/utils/testHelper.ts | 21 ++++- 8 files changed, 401 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9aa77a9d..393863eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.1.0", "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", "jsonc-parser": "^3.3.1" @@ -58,11 +58,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", @@ -78,6 +78,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", @@ -91,6 +102,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", @@ -1234,6 +1256,7 @@ "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" @@ -3231,6 +3254,7 @@ "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 57e76127..bb2afc37 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", "jsonc-parser": "^3.3.1" diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 7c1263cd..011c2017 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -491,7 +491,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( @@ -727,6 +728,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, + tagsFilter: selector.tagFilters, pageEtags: selector.pageEtags }; @@ -966,7 +968,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); } @@ -976,8 +982,8 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector return uniqueSelectors.map(selectorCandidate => { const selector = { ...selectorCandidate }; if (selector.snapshotName) { - if (selector.keyFilter || selector.labelFilter) { - throw new ArgumentError("Key or label filter should not be used for a snapshot."); + if (selector.keyFilter || selector.labelFilter || selector.tagFilters) { + throw new ArgumentError("Key, label or tag filters should not be specified while selecting a snapshot."); } } else { if (!selector.keyFilter) { @@ -989,11 +995,31 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { throw new ArgumentError("The characters '*' and ',' are not supported in label filters."); } + if (selector.tagFilters) { + validateTagFilters(selector.tagFilters); + } } return selector; }); } +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 @@ -1014,3 +1040,12 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel }); return getValidSettingSelectors(selectors); } + +function validateTagFilters(tagFilters: string[]): void { + for (const tagFilter of tagFilters) { + 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".`); + } + } +} diff --git a/src/types.ts b/src/types.ts index bef8b6b9..21ce23f0 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 { + /** + * Represents empty tag value. + */ + Null = "" +} 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 7806789d..244782e9 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,30 @@ const mockedKVs = [{ } }), contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" +}, { + key: "keyWithMultipleTags", + value: "someValue", + tags: {"tag1": "someValue", "tag2": "someValue"} +}, { + key: "keyWithTag1", + value: "someValue1", + tags: {"tag1": "someValue"} +}, { + key: "keyWithTag2", + value: "someValue2", + tags: {"tag2": "someValue"} +}, { + key: "keyWithNullTag", + value: "valueWithNullTag", + tags: {"nullTag": null} +}, { + key: "keyWithEscapedComma", + value: "valueWithEscapedComma", + tags: {"tag": "value\\,with\\,commas"} +}, { + key: "keyWithEmptyTag", + value: "valueWithEmptyTag", + tags: {"emptyTag": ""} } ].map(createMockedKeyValue); @@ -138,7 +164,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 filters should not be specified while selecting a snapshot."); }); it("should not include feature flags directly in the settings", async () => { @@ -165,6 +191,79 @@ 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: [{ + keyFilter: "*", + tagFilters: ["tag1=someValue"] + }] + }); + expect(loadWithTag1.has("keyWithTag1")).true; + expect(loadWithTag1.get("keyWithTag1")).eq("someValue1"); + expect(loadWithTag1.has("keyWithTag2")).false; + expect(loadWithTag1.has("keyWithMultipleTags")).true; + expect(loadWithTag1.get("keyWithMultipleTags")).eq("someValue"); + + const loadWithMultipleTags = await load(connectionString, { + selectors: [{ + keyFilter: "*", + 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 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, { @@ -274,6 +373,45 @@ 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: [{ + keyFilter: "*", + 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, { @@ -294,13 +432,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/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 60f629fc..de0d2470 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -26,6 +26,12 @@ function _sha256(input) { 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; @@ -38,7 +44,17 @@ 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("="); + if (tagValue === "\0") { + return kv.tags && kv.tags[tagName] === null; + } + return kv.tags && kv.tags[tagName] === tagValue; + }); + } + return keyMatched && labelMatched && tagsMatched; }); } @@ -233,8 +249,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 20947058341b9b826b53d411570b43ea00ad5da0 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:26:39 +0800 Subject: [PATCH 5/5] version bump 2.2.0 (#207) --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 393863eb..e30a06e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure/app-configuration-provider", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure/app-configuration-provider", - "version": "2.1.0", + "version": "2.2.0", "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.8.0", diff --git a/package.json b/package.json index bb2afc37..d5934885 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure/app-configuration-provider", - "version": "2.1.0", + "version": "2.2.0", "description": "The JavaScript configuration provider for Azure App Configuration", "main": "dist/index.js", "module": "./dist-esm/index.js", diff --git a/src/version.ts b/src/version.ts index 0200538f..f61789c4 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "2.1.0"; +export const VERSION = "2.2.0";