From b4455bbfd65b74fa12927e83794d72966a4ee5bb Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Wed, 8 Nov 2023 12:27:33 +0800 Subject: [PATCH 1/8] forbid wildcard in label filters (#22) --- package-lock.json | 51 ++++++++++++++--- package.json | 3 +- src/AzureAppConfigurationImpl.ts | 25 ++++++++- src/AzureAppConfigurationOptions.ts | 21 ++++++- test/load.test.js | 87 ++++++++++++++++------------- test/utils/testHelper.js | 31 ++++++++-- 6 files changed, 165 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index 71d02854..ada9f0d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,8 @@ "rollup-plugin-dts": "^5.3.0", "sinon": "^15.2.0", "tslib": "^2.6.0", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "uuid": "^9.0.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -208,6 +209,14 @@ "node": ">=14.0.0" } }, + "node_modules/@azure/identity/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@azure/keyvault-secrets": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@azure/keyvault-secrets/-/keyvault-secrets-4.7.0.tgz", @@ -272,6 +281,14 @@ "node": "10 || 12 || 14 || 16 || 18" } }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.22.13", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", @@ -3379,9 +3396,14 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -3656,6 +3678,13 @@ "stoppable": "^1.1.0", "tslib": "^2.2.0", "uuid": "^8.3.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "@azure/keyvault-secrets": { @@ -3705,6 +3734,13 @@ "@azure/msal-common": "13.3.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "@babel/code-frame": { @@ -5947,9 +5983,10 @@ } }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true }, "which": { "version": "2.0.2", diff --git a/package.json b/package.json index 148f1718..b354ca12 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "rollup-plugin-dts": "^5.3.0", "sinon": "^15.2.0", "tslib": "^2.6.0", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "uuid": "^9.0.1" }, "dependencies": { "@azure/app-configuration": "^1.4.1", diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 323ac2a8..faaf2524 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -44,7 +44,10 @@ export class AzureAppConfigurationImpl extends Map implements A public async load() { const keyValues: [key: string, value: unknown][] = []; - const selectors = this.options?.selectors ?? [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; + + // validate selectors + const selectors = getValidSelectors(this.options?.selectors); + for (const selector of selectors) { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, @@ -105,3 +108,23 @@ export class AzureAppConfigurationImpl extends Map implements A return headers; } } + +function getValidSelectors(selectors?: { keyFilter: string, labelFilter?: string }[]) { + if (!selectors || selectors.length === 0) { + // Default selector: key: *, label: \0 + return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; + } + return selectors.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."); + } + return selector; + }); +} \ No newline at end of file diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 975cb838..11a9ba5b 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -8,7 +8,26 @@ export const MaxRetries = 2; export const MaxRetryDelayInMs = 60000; export interface AzureAppConfigurationOptions { - selectors?: { keyFilter: string, labelFilter: string }[]; + /** + * Specify what key-values to include in the configuration provider. include multiple sets of key-values + * + * @property keyFilter: + * The key filter to apply when querying Azure App Configuration for key-values. + * An asterisk `*` can be added to the end to return all key-values whose key begins with the key filter. + * e.g. key filter `abc*` returns all key-values whose key starts with `abc`. + * A comma `,` can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. + * Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. + * E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. + * For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\). + * e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. + * + * @property labelFilter: + * The label filter to apply when querying Azure App Configuration for key-values. + * By default, the "null label" will be used, matching key-values without a label. + * The characters asterisk `*` and comma `,` are not supported. + * Backslash `\` character is reserved and must be escaped using another backslash `\`. + */ + selectors?: { keyFilter: string, labelFilter?: string }[]; trimKeyPrefixes?: string[]; clientOptions?: AppConfigurationClientOptions; keyVaultOptions?: AzureAppConfigurationKeyVaultOptions; diff --git a/test/load.test.js b/test/load.test.js index 5a8682a9..36d9d8bf 100644 --- a/test/load.test.js +++ b/test/load.test.js @@ -12,54 +12,30 @@ const { createMockedConnectionString, createMockedEnpoint, createMockedTokenCredential, + createMockedKeyValue, } = require("./utils/testHelper"); const mockedKVs = [{ - value: "red", key: "app.settings.fontColor", - label: null, - contentType: "", - lastModified: "2023-05-04T04:34:24.000Z", - tags: {}, - etag: "210fjkPIWZMjFTi_qyEEmmsJjtUjj0YQl-Y3s1m6GLw", - isReadOnly: false + value: "red", }, { - value: "40", key: "app.settings.fontSize", - label: null, - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false + value: "40", }, { - value: "TestValue", key: "TestKey", label: "Test", - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false + value: "TestValue", +}, { + key: "TestKey", + label: "Prod", + value: "TestValueForProd", }, { - value: null, key: "KeyForNullValue", - label: "", - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false + value: null, }, { - value: "", key: "KeyForEmptyValue", - label: "", - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false -}]; + value: "", +}].map(createMockedKeyValue); describe("load", function () { before(() => { @@ -118,8 +94,7 @@ describe("load", function () { expect(settings.get("fontColor")).eq("red"); expect(settings.has("fontSize")).eq(true); expect(settings.get("fontSize")).eq("40"); - expect(settings.has("TestKey")).eq(true); - expect(settings.get("TestKey")).eq("TestValue"); + expect(settings.has("TestKey")).eq(false); }); it("should trim longest key prefix first", async () => { @@ -136,8 +111,7 @@ describe("load", function () { expect(settings.get("fontColor")).eq("red"); expect(settings.has("fontSize")).eq(true); expect(settings.get("fontSize")).eq("40"); - expect(settings.has("Key")).eq(true); - expect(settings.get("Key")).eq("TestValue"); + expect(settings.has("TestKey")).eq(false); }); it("should support null/empty value", async () => { @@ -149,4 +123,39 @@ describe("load", function () { expect(settings.has("KeyForEmptyValue")).eq(true); expect(settings.get("KeyForEmptyValue")).eq(""); }); + + it("should not support * or , in label filters", async () => { + const connectionString = createMockedConnectionString(); + const loadWithWildcardLabelFilter = load(connectionString, { + selectors: [{ + keyFilter: "app.*", + labelFilter: "*" + }] + }); + expect(loadWithWildcardLabelFilter).to.eventually.rejected; + + const loadWithMultipleLabelFilter = load(connectionString, { + selectors: [{ + keyFilter: "app.*", + labelFilter: "labelA,labelB" + }] + }); + expect(loadWithMultipleLabelFilter).to.eventually.rejected; + }); + + 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.has("TestKey")).eq(true); + expect(settings.get("TestKey")).eq("TestValueForProd"); + }); }) diff --git a/test/utils/testHelper.js b/test/utils/testHelper.js index 11e43f71..b58aa859 100644 --- a/test/utils/testHelper.js +++ b/test/utils/testHelper.js @@ -5,16 +5,27 @@ const sinon = require("sinon"); const { AppConfigurationClient } = require("@azure/app-configuration"); const { ClientSecretCredential } = require("@azure/identity"); const { SecretClient } = require("@azure/keyvault-secrets"); +const uuid = require("uuid"); const TEST_CLIENT_ID = "62e76eb5-218e-4f90-8261-000000000000"; const TEST_TENANT_ID = "72f988bf-86f1-41af-91ab-000000000000"; const TEST_CLIENT_SECRET = "Q158Q~2JtUwVbuq0Mzm9ocH2umTB000000000000"; function mockAppConfigurationClientListConfigurationSettings(kvList) { - function* testKvSetGnerator() { - yield* kvList; + function* testKvSetGnerator(kvs) { + yield* kvs; } - sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake(() => testKvSetGnerator()); + sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake((listOptions) => { + const keyFilter = listOptions.keyFilter ?? "*"; + const labelFilter = listOptions.labelFilter ?? "*"; + const kvs = kvList.filter(kv => { + const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, keyFilter.length - 1)) : kv.key === keyFilter; + const labelMatched = labelFilter.endsWith("*") ? kv.label.startsWith(labelFilter.slice(0, labelFilter.length - 1)) + : (labelFilter === "\0" ? kv.label === null : kv.label === labelFilter); // '\0' in labelFilter, null in config setting. + return keyMatched && labelMatched; + }) + return testKvSetGnerator(kvs); + }); } // uriValueList: [["", "value"], ...] @@ -74,6 +85,17 @@ const createMockedJsonKeyValue = (key, value) => ({ isReadOnly: false }); +const createMockedKeyValue = (props) => (Object.assign({ + value: "TestValue", + key: "TestKey", + label: null, + contentType: "", + lastModified: new Date().toISOString(), + tags: {}, + etag: uuid.v4(), + isReadOnly: false +}, props)); + module.exports = { sinon, mockAppConfigurationClientListConfigurationSettings, @@ -84,5 +106,6 @@ module.exports = { createMockedConnectionString, createMockedTokenCredential, createMockedKeyVaultReference, - createMockedJsonKeyValue + createMockedJsonKeyValue, + createMockedKeyValue } \ No newline at end of file From 5f35f2d5b96d43769e859d1b9e24955217e101f8 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Thu, 9 Nov 2023 04:59:46 +0800 Subject: [PATCH 2/8] Rewrite test cases in TS (#24) --- .eslintrc | 3 +- .gitignore | 3 +- package-lock.json | 57 +++++++++++++++++++ package.json | 12 ++-- src/AzureAppConfiguration.ts | 2 +- test/.eslintrc | 5 -- ...tOptions.test.js => clientOptions.test.ts} | 15 +++-- test/exportedApi.ts | 4 ++ test/{json.test.js => json.test.ts} | 14 ++--- test/{keyvault.test.js => keyvault.test.ts} | 17 +++--- test/{load.test.js => load.test.ts} | 15 ++--- ...Tracing.test.js => requestTracing.test.ts} | 19 ++++--- test/utils/{testHelper.js => testHelper.ts} | 43 +++++++------- tsconfig.base.json | 24 ++++++++ tsconfig.json | 17 +----- tsconfig.test.json | 10 ++++ 16 files changed, 167 insertions(+), 93 deletions(-) delete mode 100644 test/.eslintrc rename test/{clientOptions.test.js => clientOptions.test.ts} (86%) create mode 100644 test/exportedApi.ts rename test/{json.test.js => json.test.ts} (90%) rename test/{keyvault.test.js => keyvault.test.ts} (84%) rename test/{load.test.js => load.test.ts} (91%) rename test/{requestTracing.test.js => requestTracing.test.ts} (86%) rename test/utils/{testHelper.js => testHelper.ts} (68%) create mode 100644 tsconfig.base.json create mode 100644 tsconfig.test.json diff --git a/.eslintrc b/.eslintrc index 754a9f38..8b96047e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -40,5 +40,6 @@ "avoidEscape": true } ], - }, + "@typescript-eslint/no-explicit-any": "off" + } } \ No newline at end of file diff --git a/.gitignore b/.gitignore index d946d028..1ccd7b97 100644 --- a/.gitignore +++ b/.gitignore @@ -400,6 +400,7 @@ FodyWeavers.xsd # bundled folder dist/ dist-esm/ +out/ types/ # dotenv @@ -409,4 +410,4 @@ types/ *.tgz # examples -examples/package-lock.json \ No newline at end of file +examples/package-lock.json diff --git a/package-lock.json b/package-lock.json index ada9f0d9..9987ca56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,10 @@ }, "devDependencies": { "@rollup/plugin-typescript": "^11.1.2", + "@types/mocha": "^10.0.4", "@types/node": "^20.5.7", + "@types/sinon": "^17.0.1", + "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", "chai": "^4.3.7", @@ -832,6 +835,12 @@ "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", "dev": true }, + "node_modules/@types/mocha": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.4.tgz", + "integrity": "sha512-xKU7bUjiFTIttpWaIZ9qvgg+22O1nmbA+HRxdlR+u6TWsGfmFdXrheJoK4fFxrHNVIOBDvDNKZG+LYBpMHpX3w==", + "dev": true + }, "node_modules/@types/node": { "version": "20.8.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", @@ -847,6 +856,27 @@ "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", "dev": true }, + "node_modules/@types/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-Q2Go6TJetYn5Za1+RJA1Aik61Oa2FS8SuJ0juIqUuJ5dZR4wvhKfmSdIqWtQ3P6gljKWjW0/R7FZkA4oXVL6OA==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, + "node_modules/@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.8.0.tgz", @@ -4152,6 +4182,12 @@ "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", "dev": true }, + "@types/mocha": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.4.tgz", + "integrity": "sha512-xKU7bUjiFTIttpWaIZ9qvgg+22O1nmbA+HRxdlR+u6TWsGfmFdXrheJoK4fFxrHNVIOBDvDNKZG+LYBpMHpX3w==", + "dev": true + }, "@types/node": { "version": "20.8.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", @@ -4167,6 +4203,27 @@ "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", "dev": true }, + "@types/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-Q2Go6TJetYn5Za1+RJA1Aik61Oa2FS8SuJ0juIqUuJ5dZR4wvhKfmSdIqWtQ3P6gljKWjW0/R7FZkA4oXVL6OA==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, + "@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.8.0.tgz", diff --git a/package.json b/package.json index b354ca12..7f7ef05e 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,15 @@ "README.md" ], "scripts": { - "build": "npm run clean && npm run build-cjs && npm run build-esm", + "build": "npm run clean && npm run build-cjs && npm run build-esm && npm run build-test", "build-cjs": "rollup --config", - "build-esm": "tsc -p ./", - "clean": "rimraf dist dist-esm types", + "build-esm": "tsc -p ./tsconfig.json", + "build-test": "tsc -p ./tsconfig.test.json", + "clean": "rimraf dist dist-esm out types", "dev": "rollup --config --watch", "lint": "eslint src/ test/", "fix-lint": "eslint src/ test/ --fix", - "test": "mocha --timeout 10000" + "test": "mocha out/test/*.test.{js,cjs,mjs} --timeout 10000" }, "repository": { "type": "git", @@ -33,7 +34,10 @@ "license": "MIT", "devDependencies": { "@rollup/plugin-typescript": "^11.1.2", + "@types/mocha": "^10.0.4", "@types/node": "^20.5.7", + "@types/sinon": "^17.0.1", + "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", "chai": "^4.3.7", diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index cd4cdc2a..a4af8746 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -3,4 +3,4 @@ export type AzureAppConfiguration = { // methods for advanced features, e.g. refresh() -} & ReadonlyMap; +} & ReadonlyMap; diff --git a/test/.eslintrc b/test/.eslintrc deleted file mode 100644 index a9331162..00000000 --- a/test/.eslintrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "@typescript-eslint/no-var-requires": "off" - } -} \ No newline at end of file diff --git a/test/clientOptions.test.js b/test/clientOptions.test.ts similarity index 86% rename from test/clientOptions.test.js rename to test/clientOptions.test.ts index ebd615b2..c5496fb9 100644 --- a/test/clientOptions.test.js +++ b/test/clientOptions.test.ts @@ -1,15 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -const { load } = require("../dist/index"); -const { createMockedConnectionString } = require("./utils/testHelper"); -const nock = require("nock"); +import { load } from "./exportedApi"; +import { createMockedConnectionString } from "./utils/testHelper"; +import * as nock from "nock"; class HttpRequestCountPolicy { + count: number; + name: string; + constructor() { this.count = 0; this.name = "HttpRequestCountPolicy"; @@ -58,7 +61,7 @@ describe("custom client options", function () { it("should override default retry options", async () => { const countPolicy = new HttpRequestCountPolicy(); - const loadWithMaxRetries = (maxRetries) => { + const loadWithMaxRetries = (maxRetries: number) => { return load(createMockedConnectionString(fakeEndpoint), { clientOptions: { additionalPolicies: [{ diff --git a/test/exportedApi.ts b/test/exportedApi.ts new file mode 100644 index 00000000..7e6feba1 --- /dev/null +++ b/test/exportedApi.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { load } from "../src"; \ No newline at end of file diff --git a/test/json.test.js b/test/json.test.ts similarity index 90% rename from test/json.test.js rename to test/json.test.ts index 24e18de5..4818e9b2 100644 --- a/test/json.test.js +++ b/test/json.test.ts @@ -1,18 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -const { load } = require("../dist/index"); -const { - mockAppConfigurationClientListConfigurationSettings, - restoreMocks, - createMockedConnectionString, - createMockedKeyVaultReference, - createMockedJsonKeyValue -} = require("./utils/testHelper"); +import { load } from "./exportedApi"; +import { mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedKeyVaultReference, createMockedJsonKeyValue } from "./utils/testHelper"; const jsonKeyValue = createMockedJsonKeyValue("json.settings.logging", '{"Test":{"Level":"Debug"},"Prod":{"Level":"Warning"}}'); const keyVaultKeyValue = createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"); diff --git a/test/keyvault.test.js b/test/keyvault.test.ts similarity index 84% rename from test/keyvault.test.js rename to test/keyvault.test.ts index 02d676d7..c5ac332e 100644 --- a/test/keyvault.test.js +++ b/test/keyvault.test.ts @@ -1,16 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -const { load } = require("../dist/index"); -const { sinon, - createMockedConnectionString, - createMockedTokenCredential, - mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } = require("./utils/testHelper"); -const { SecretClient } = require("@azure/keyvault-secrets"); +import { load } from "./exportedApi"; +import { sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } from "./utils/testHelper"; +import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; const mockedData = [ // key, secretUri, value @@ -72,9 +69,9 @@ describe("key vault reference", function () { // mock specific behavior per secret client const client1 = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); - sinon.stub(client1, "getSecret").returns({ value: "SecretValueViaClient1" }); + sinon.stub(client1, "getSecret").returns(Promise.resolve({value: "SecretValueViaClient1" } as KeyVaultSecret)); const client2 = new SecretClient("https://fake-vault-name2.vault.azure.net", createMockedTokenCredential()); - sinon.stub(client2, "getSecret").returns({ value: "SecretValueViaClient2" }); + sinon.stub(client2, "getSecret").returns(Promise.resolve({value: "SecretValueViaClient2" } as KeyVaultSecret)); const settings = await load(createMockedConnectionString(), { keyVaultOptions: { secretClients: [ diff --git a/test/load.test.js b/test/load.test.ts similarity index 91% rename from test/load.test.js rename to test/load.test.ts index 36d9d8bf..3e3d4c10 100644 --- a/test/load.test.js +++ b/test/load.test.ts @@ -1,19 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -const { load } = require("../dist/index"); -const { - mockAppConfigurationClientListConfigurationSettings, - restoreMocks, - createMockedConnectionString, - createMockedEnpoint, - createMockedTokenCredential, - createMockedKeyValue, -} = require("./utils/testHelper"); +import { load } from "./exportedApi"; +import { mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedEnpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper"; const mockedKVs = [{ key: "app.settings.fontColor", diff --git a/test/requestTracing.test.js b/test/requestTracing.test.ts similarity index 86% rename from test/requestTracing.test.js rename to test/requestTracing.test.ts index 533cd7a3..aaedae20 100644 --- a/test/requestTracing.test.js +++ b/test/requestTracing.test.ts @@ -1,14 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -const { load } = require("../dist/index"); -const { createMockedConnectionString, createMockedTokenCredential } = require("./utils/testHelper"); - +import { createMockedConnectionString, createMockedTokenCredential } from "./utils/testHelper"; +import { load } from "./exportedApi"; class HttpRequestHeadersPolicy { + headers: any; + name: string; + constructor() { this.headers = {}; this.name = "HttpRequestHeadersPolicy"; @@ -22,13 +24,14 @@ class HttpRequestHeadersPolicy { describe("request tracing", function () { const fakeEndpoint = "https://127.0.0.1"; // sufficient to test the request it sends out const headerPolicy = new HttpRequestHeadersPolicy(); + const position: "perCall" | "perRetry" = "perCall"; const clientOptions = { retryOptions: { maxRetries: 0 // save time }, additionalPolicies: [{ policy: headerPolicy, - position: "perCall" + position }] }; @@ -43,7 +46,7 @@ describe("request tracing", function () { await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; - expect(headerPolicy.headers.get("User-Agent")).satisfy(ua => ua.startsWith("javascript-appconfiguration-provider")); + expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); }); it("should have request type in correlation-context header", async () => { @@ -99,7 +102,7 @@ describe("request tracing", function () { delete process.env.WEBSITE_SITE_NAME; }); - it("should disable request tracing when AZURE_APP_CONFIGURATION_TRACING_DISABLED is true", async() => { + it("should disable request tracing when AZURE_APP_CONFIGURATION_TRACING_DISABLED is true", async () => { for (const indicator of ["TRUE", "true"]) { process.env.AZURE_APP_CONFIGURATION_TRACING_DISABLED = indicator; try { diff --git a/test/utils/testHelper.js b/test/utils/testHelper.ts similarity index 68% rename from test/utils/testHelper.js rename to test/utils/testHelper.ts index b58aa859..6db4bf62 100644 --- a/test/utils/testHelper.js +++ b/test/utils/testHelper.ts @@ -1,47 +1,50 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const sinon = require("sinon"); -const { AppConfigurationClient } = require("@azure/app-configuration"); -const { ClientSecretCredential } = require("@azure/identity"); -const { SecretClient } = require("@azure/keyvault-secrets"); -const uuid = require("uuid"); +import * as sinon from "sinon"; +import { AppConfigurationClient } from "@azure/app-configuration"; +import { ClientSecretCredential } from "@azure/identity"; +import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; +import * as uuid from "uuid"; -const TEST_CLIENT_ID = "62e76eb5-218e-4f90-8261-000000000000"; -const TEST_TENANT_ID = "72f988bf-86f1-41af-91ab-000000000000"; -const TEST_CLIENT_SECRET = "Q158Q~2JtUwVbuq0Mzm9ocH2umTB000000000000"; +const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; +const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000"; +const TEST_CLIENT_SECRET = "0000000000000000000000000000000000000000"; -function mockAppConfigurationClientListConfigurationSettings(kvList) { - function* testKvSetGnerator(kvs) { +function mockAppConfigurationClientListConfigurationSettings(kvList: any[]) { + function* testKvSetGnerator(kvs: any[]) { yield* kvs; } sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake((listOptions) => { - const keyFilter = listOptions.keyFilter ?? "*"; - const labelFilter = listOptions.labelFilter ?? "*"; + const keyFilter = listOptions?.keyFilter ?? "*"; + const labelFilter = listOptions?.labelFilter ?? "*"; const kvs = kvList.filter(kv => { const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, keyFilter.length - 1)) : kv.key === keyFilter; const labelMatched = labelFilter.endsWith("*") ? kv.label.startsWith(labelFilter.slice(0, labelFilter.length - 1)) : (labelFilter === "\0" ? kv.label === null : kv.label === labelFilter); // '\0' in labelFilter, null in config setting. return keyMatched && labelMatched; }) - return testKvSetGnerator(kvs); + return testKvSetGnerator(kvs) as any; }); } // uriValueList: [["", "value"], ...] -function mockSecretClientGetSecret(uriValueList) { +function mockSecretClientGetSecret(uriValueList: [string, string][]) { const dict = new Map(); for (const [uri, value] of uriValueList) { dict.set(uri, value); } - sinon.stub(SecretClient.prototype, "getSecret").callsFake(function (secretName, options) { + sinon.stub(SecretClient.prototype, "getSecret").callsFake(async function (secretName, options) { const url = new URL(this.vaultUrl); url.pathname = `/secrets/${secretName}`; if (options?.version) { url.pathname += `/${options.version}`; } - return { value: dict.get(url.toString()) }; + return { + name: secretName, + value: dict.get(url.toString()) + } as KeyVaultSecret; }) } @@ -61,7 +64,7 @@ const createMockedTokenCredential = (tenantId = TEST_TENANT_ID, clientId = TEST_ return new ClientSecretCredential(tenantId, clientId, clientSecret); } -const createMockedKeyVaultReference = (key, vaultUri) => ({ +const createMockedKeyVaultReference = (key: string, vaultUri: string) => ({ // https://${vaultName}.vault.azure.net/secrets/${secretName} value: `{"uri":"${vaultUri}"}`, key, @@ -74,7 +77,7 @@ const createMockedKeyVaultReference = (key, vaultUri) => ({ isReadOnly: false, }); -const createMockedJsonKeyValue = (key, value) => ({ +const createMockedJsonKeyValue = (key: string, value: any) => ({ value: value, key: key, label: null, @@ -85,7 +88,7 @@ const createMockedJsonKeyValue = (key, value) => ({ isReadOnly: false }); -const createMockedKeyValue = (props) => (Object.assign({ +const createMockedKeyValue = (props: {[key: string]: any}) => (Object.assign({ value: "TestValue", key: "TestKey", label: null, @@ -96,7 +99,7 @@ const createMockedKeyValue = (props) => (Object.assign({ isReadOnly: false }, props)); -module.exports = { +export { sinon, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..0f96a196 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 395c4d54..f2fc0e9b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,25 +1,10 @@ { + "extends": "./tsconfig.base.json", "compilerOptions": { - "lib": [ - "DOM", - "WebWorker", - "ESNext" - ], - "skipDefaultLibCheck": true, "module": "ESNext", - "moduleResolution": "Node", - "target": "ES2022", - "strictNullChecks": true, - "strictFunctionTypes": true, - "sourceMap": true, - "inlineSources": true, "outDir": "./dist-esm" }, "include": [ "src/**/*" - ], - "exclude": [ - "node_modules", - "**/node_modules/*" ] } \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..3cbd3c0a --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./out" + }, + "include": [ + "test/**/*" + ] +} \ No newline at end of file From bae1b0ec8f1317fdba9169caf57a6198f9c9deb0 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Thu, 9 Nov 2023 08:36:32 +0800 Subject: [PATCH 3/8] update @azure/core-rest-pipeline to 1.12.2 (#25) --- package-lock.json | 14 +++++++------- test/clientOptions.test.ts | 31 ++++++++++++++++++++++++------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9987ca56..d500c31e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -145,9 +145,9 @@ } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.12.1.tgz", - "integrity": "sha512-SsyWQ+T5MFQRX+M8H/66AlaI6HyCbQStGfFngx2fuiW+vKI2DkhtOvbYodPyf9fOe/ARLWWc3ohX54lQ5Kmaog==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.12.2.tgz", + "integrity": "sha512-wLLJQdL4v1yoqYtEtjKNjf8pJ/G/BqVomAWxcKOR1KbZJyCEnCv04yks7Y1NhJ3JzxbDs307W67uX0JzklFdCg==", "dependencies": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.4.0", @@ -160,7 +160,7 @@ "tslib": "^2.2.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" } }, "node_modules/@azure/core-tracing": { @@ -3655,9 +3655,9 @@ } }, "@azure/core-rest-pipeline": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.12.1.tgz", - "integrity": "sha512-SsyWQ+T5MFQRX+M8H/66AlaI6HyCbQStGfFngx2fuiW+vKI2DkhtOvbYodPyf9fOe/ARLWWc3ohX54lQ5Kmaog==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.12.2.tgz", + "integrity": "sha512-wLLJQdL4v1yoqYtEtjKNjf8pJ/G/BqVomAWxcKOR1KbZJyCEnCv04yks7Y1NhJ3JzxbDs307W67uX0JzklFdCg==", "requires": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.4.0", diff --git a/test/clientOptions.test.ts b/test/clientOptions.test.ts index c5496fb9..2bf146f1 100644 --- a/test/clientOptions.test.ts +++ b/test/clientOptions.test.ts @@ -28,12 +28,12 @@ class HttpRequestCountPolicy { describe("custom client options", function () { const fakeEndpoint = "https://azure.azconfig.io"; - before(() => { + beforeEach(() => { // Thus here mock it to reply 500, in which case the retry mechanism works. nock(fakeEndpoint).persist().get(() => true).reply(500); }); - after(() => { + afterEach(() => { nock.restore(); }) @@ -96,9 +96,26 @@ describe("custom client options", function () { expect(countPolicy.count).eq(2); }); - // Note: - // core-rest-pipeline skips the retry throwing `RestError: getaddrinfo ENOTFOUND azure.azconfig.io` - // Probably would be fixed in upstream libs. - // See https://github.com/Azure/azure-sdk-for-js/issues/27037 - it("should retry on DNS failure"); + it("should retry on DNS failure", async () => { + nock.restore(); // stop mocking with 500 error but sending real requests which will fail with ENOTFOUND + const countPolicy = new HttpRequestCountPolicy(); + const loadPromise = () => { + return load(createMockedConnectionString(fakeEndpoint), { + clientOptions: { + additionalPolicies: [{ + policy: countPolicy, + position: "perRetry" + }] + } + }) + }; + let error; + try { + await loadPromise(); + } catch (e) { + error = e; + } + expect(error).not.undefined; + expect(countPolicy.count).eq(3); + }); }) From 84637e1b30174adba2bbf381484a31cbe584bedc Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Thu, 9 Nov 2023 08:36:47 +0800 Subject: [PATCH 4/8] update CI: Node.js 16.x end-of-life (#26) --- .github/workflows/ci.yml | 2 +- azure-pipelines.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 613b189e..81490d40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [16.x, 18.x, 20.x] + node-version: [18.x, 20.x] steps: - uses: actions/checkout@v3 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 53ac8f1e..f1925cb2 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -12,7 +12,7 @@ pool: steps: - task: NodeTool@0 inputs: - versionSpec: '16.x' + versionSpec: '18.x' displayName: 'Install Node.js' - script: | From aded5bb33837706df569b3b6fbc034b8c72d0e2f Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Thu, 9 Nov 2023 09:39:06 +0800 Subject: [PATCH 5/8] update @azure/app-configuration to 1.5.0 (#27) --- package-lock.json | 45 ++++++++++++++++++++++++++++++++++++--------- package.json | 2 +- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index d500c31e..398cff78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0-preview", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.4.1", + "@azure/app-configuration": "^1.5.0", "@azure/identity": "^3.3.2", "@azure/keyvault-secrets": "^4.7.0" }, @@ -57,14 +57,15 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.4.1.tgz", - "integrity": "sha512-9vLmveQR3U5DmMGkXU6fQzG7vf+GWMM+1g6zf/5Ro81dOP0R2fPDdBOd4Edg2pZpuswsf2t6QE+wo/oCeaVLZA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.5.0.tgz", + "integrity": "sha512-YlXwWc/weDFCk12arPkfskXDGxDaSyAA7JaztSVQ0y/IS7GFYqmIj3RTKbsNUSSuGLrKqcxwJ7y3vY9UmHgsdA==", "dependencies": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", - "@azure/core-http-compat": "^1.2.0", + "@azure/core-http-compat": "^2.0.0", + "@azure/core-lro": "^2.5.1", "@azure/core-paging": "^1.4.0", "@azure/core-rest-pipeline": "^1.6.0", "@azure/core-tracing": "^1.0.0", @@ -72,6 +73,19 @@ "@azure/logger": "^1.0.0", "tslib": "^2.2.0" }, + "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", + "integrity": "sha512-xpQZz/q7E0jSW4rckrTo2mDFDQgo6I69hBU4voMQi7REi6JRW5a+KfVkbJCFCWnkFmP6cAJ0IbuudTdf/MEBOQ==", + "dependencies": { + "@azure/abort-controller": "^1.0.4", + "@azure/core-client": "^1.3.0", + "@azure/core-rest-pipeline": "^1.3.0" + }, "engines": { "node": ">=14.0.0" } @@ -3585,20 +3599,33 @@ } }, "@azure/app-configuration": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.4.1.tgz", - "integrity": "sha512-9vLmveQR3U5DmMGkXU6fQzG7vf+GWMM+1g6zf/5Ro81dOP0R2fPDdBOd4Edg2pZpuswsf2t6QE+wo/oCeaVLZA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.5.0.tgz", + "integrity": "sha512-YlXwWc/weDFCk12arPkfskXDGxDaSyAA7JaztSVQ0y/IS7GFYqmIj3RTKbsNUSSuGLrKqcxwJ7y3vY9UmHgsdA==", "requires": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", - "@azure/core-http-compat": "^1.2.0", + "@azure/core-http-compat": "^2.0.0", + "@azure/core-lro": "^2.5.1", "@azure/core-paging": "^1.4.0", "@azure/core-rest-pipeline": "^1.6.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.1.0", "@azure/logger": "^1.0.0", "tslib": "^2.2.0" + }, + "dependencies": { + "@azure/core-http-compat": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.0.1.tgz", + "integrity": "sha512-xpQZz/q7E0jSW4rckrTo2mDFDQgo6I69hBU4voMQi7REi6JRW5a+KfVkbJCFCWnkFmP6cAJ0IbuudTdf/MEBOQ==", + "requires": { + "@azure/abort-controller": "^1.0.4", + "@azure/core-client": "^1.3.0", + "@azure/core-rest-pipeline": "^1.3.0" + } + } } }, "@azure/core-auth": { diff --git a/package.json b/package.json index 7f7ef05e..74eca211 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "uuid": "^9.0.1" }, "dependencies": { - "@azure/app-configuration": "^1.4.1", + "@azure/app-configuration": "^1.5.0", "@azure/identity": "^3.3.2", "@azure/keyvault-secrets": "^4.7.0" } From 8d4187dc8f436232a2c4ce4592d095191204e784 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Fri, 10 Nov 2023 03:06:31 +0800 Subject: [PATCH 6/8] Update mocked func for app-configuration v1.5.0 (#28) --- test/utils/testHelper.ts | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 6db4bf62..679069c4 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import * as sinon from "sinon"; -import { AppConfigurationClient } from "@azure/app-configuration"; +import { AppConfigurationClient, ConfigurationSetting } from "@azure/app-configuration"; import { ClientSecretCredential } from "@azure/identity"; import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; import * as uuid from "uuid"; @@ -11,7 +11,7 @@ const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000"; const TEST_CLIENT_SECRET = "0000000000000000000000000000000000000000"; -function mockAppConfigurationClientListConfigurationSettings(kvList: any[]) { +function mockAppConfigurationClientListConfigurationSettings(kvList: ConfigurationSetting[]) { function* testKvSetGnerator(kvs: any[]) { yield* kvs; } @@ -19,9 +19,18 @@ function mockAppConfigurationClientListConfigurationSettings(kvList: any[]) { const keyFilter = listOptions?.keyFilter ?? "*"; const labelFilter = listOptions?.labelFilter ?? "*"; const kvs = kvList.filter(kv => { - const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, keyFilter.length - 1)) : kv.key === keyFilter; - const labelMatched = labelFilter.endsWith("*") ? kv.label.startsWith(labelFilter.slice(0, labelFilter.length - 1)) - : (labelFilter === "\0" ? kv.label === null : kv.label === labelFilter); // '\0' in labelFilter, null in config setting. + const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter; + + let labelMatched = false; + if (labelFilter === "*") { + labelMatched = true; + } else if (labelFilter === "\0") { + labelMatched = kv.label === undefined; + } else if (labelFilter.endsWith("*")) { + labelMatched = kv.label !== undefined && kv.label.startsWith(labelFilter.slice(0, -1)); + } else { + labelMatched = kv.label === labelFilter; + } return keyMatched && labelMatched; }) return testKvSetGnerator(kvs) as any; @@ -64,36 +73,33 @@ const createMockedTokenCredential = (tenantId = TEST_TENANT_ID, clientId = TEST_ return new ClientSecretCredential(tenantId, clientId, clientSecret); } -const createMockedKeyVaultReference = (key: string, vaultUri: string) => ({ +const createMockedKeyVaultReference = (key: string, vaultUri: string): ConfigurationSetting => ({ // https://${vaultName}.vault.azure.net/secrets/${secretName} value: `{"uri":"${vaultUri}"}`, key, - label: null, contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", - lastModified: "2023-05-09T08:51:11.000Z", + lastModified: new Date(), tags: { }, etag: "SPJSMnJ2ph4BAjftWfdIctV2VIyQxtcIzRbh1oxTBkM", isReadOnly: false, }); -const createMockedJsonKeyValue = (key: string, value: any) => ({ +const createMockedJsonKeyValue = (key: string, value: any): ConfigurationSetting => ({ value: value, key: key, - label: null, contentType: "application/json", - lastModified: "2023-05-04T04:32:56.000Z", + lastModified: new Date(), tags: {}, etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", isReadOnly: false }); -const createMockedKeyValue = (props: {[key: string]: any}) => (Object.assign({ +const createMockedKeyValue = (props: {[key: string]: any}): ConfigurationSetting => (Object.assign({ value: "TestValue", key: "TestKey", - label: null, contentType: "", - lastModified: new Date().toISOString(), + lastModified: new Date(), tags: {}, etag: uuid.v4(), isReadOnly: false From 33f033e0a8216ae99e01deefc090935aeaded708 Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Tue, 28 Nov 2023 09:48:26 +0800 Subject: [PATCH 7/8] add TSdoc for public API and types (#32) --- src/AzureAppConfigurationOptions.ts | 14 ++++++++++++++ .../AzureAppConfigurationKeyVaultOptions.ts | 16 ++++++++++++++++ src/load.ts | 11 +++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 11a9ba5b..de3b6da3 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -28,7 +28,21 @@ export interface AzureAppConfigurationOptions { * Backslash `\` character is reserved and must be escaped using another backslash `\`. */ selectors?: { keyFilter: string, labelFilter?: string }[]; + + /** + * Specifies prefixes to be trimmed from the keys of all key-values retrieved from Azure App Configuration. + * This is useful when you want to remove a common prefix from all keys to avoid repetition. + * The provided prefixes will be sorted in descending order and the longest matching prefix will be trimmed first. + */ trimKeyPrefixes?: string[]; + + /** + * Specifies custom options to be used when creating the AppConfigurationClient. + */ clientOptions?: AppConfigurationClientOptions; + + /** + * Specifies options used to resolve Vey Vault references. + */ keyVaultOptions?: AzureAppConfigurationKeyVaultOptions; } \ No newline at end of file diff --git a/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts b/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts index 9b85beef..2b5c7d87 100644 --- a/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts +++ b/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts @@ -4,8 +4,24 @@ import { TokenCredential } from "@azure/identity"; import { SecretClient } from "@azure/keyvault-secrets"; +/** + * Options used to resolve Key Vault references. + */ export interface AzureAppConfigurationKeyVaultOptions { + /** + * Specifies the Key Vault secret client used for resolving Key Vault references. + */ secretClients?: SecretClient[]; + + /** + * Specifies the credentials used to authenticate to key vaults that have no applied SecretClient. + */ credential?: TokenCredential; + + /** + * Specifies the callback used to resolve key vault references that have no applied SecretClient. + * @param keyVaultReference The Key Vault reference to resolve. + * @returns The secret value. + */ secretResolver?: (keyVaultReference: URL) => string | Promise; } \ No newline at end of file diff --git a/src/load.ts b/src/load.ts index dbb64196..a44d4042 100644 --- a/src/load.ts +++ b/src/load.ts @@ -8,7 +8,18 @@ import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl"; import { AzureAppConfigurationOptions, MaxRetries, MaxRetryDelayInMs } from "./AzureAppConfigurationOptions"; import * as RequestTracing from "./requestTracing/constants"; +/** + * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. + * @param connectionString The connection string for the App Configuration store. + * @param options Optional parameters. + */ export async function load(connectionString: string, options?: AzureAppConfigurationOptions): Promise; +/** + * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. + * @param endpoint The URL to the App Configuration store. + * @param credential The credential to use to connect to the App Configuration store. + * @param options Optional parameters. + */ export async function load(endpoint: URL | string, credential: TokenCredential, options?: AzureAppConfigurationOptions): Promise; export async function load( connectionStringOrEndpoint: string | URL, From e8292076d5d25f9b8aa17f4200434c905c19958c Mon Sep 17 00:00:00 2001 From: Yan Zhang <2351748+Eskibear@users.noreply.github.com> Date: Thu, 30 Nov 2023 14:26:36 +0800 Subject: [PATCH 8/8] Dedup exact same selectors while keep original precedence (#31) --- src/AzureAppConfigurationImpl.ts | 19 ++++++++++--- src/AzureAppConfigurationOptions.ts | 22 +++------------ src/KeyFilter.ts | 6 ---- src/LabelFilter.ts | 6 ---- src/index.ts | 3 +- src/types.ts | 44 +++++++++++++++++++++++++++++ test/load.test.ts | 22 ++++++++++++++- 7 files changed, 85 insertions(+), 37 deletions(-) delete mode 100644 src/KeyFilter.ts delete mode 100644 src/LabelFilter.ts create mode 100644 src/types.ts diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index faaf2524..3c682502 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -6,11 +6,11 @@ import { AzureAppConfiguration } from "./AzureAppConfiguration"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions"; import { IKeyValueAdapter } from "./IKeyValueAdapter"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter"; -import { KeyFilter } from "./KeyFilter"; -import { LabelFilter } from "./LabelFilter"; +import { KeyFilter, LabelFilter } from "./types"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter"; import { CorrelationContextHeaderName } from "./requestTracing/constants"; import { createCorrelationContextHeader, requestTracingEnabled } from "./requestTracing/utils"; +import { SettingSelector } from "./types"; export class AzureAppConfigurationImpl extends Map implements AzureAppConfiguration { private adapters: IKeyValueAdapter[] = []; @@ -109,12 +109,23 @@ export class AzureAppConfigurationImpl extends Map implements A } } -function getValidSelectors(selectors?: { keyFilter: string, labelFilter?: string }[]) { +function getValidSelectors(selectors?: SettingSelector[]) { if (!selectors || selectors.length === 0) { // Default selector: key: *, label: \0 return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; } - return selectors.map(selectorCandidate => { + + // below code dedupes selectors by keyFilter and labelFilter, the latter selector wins + const dedupedSelectors: SettingSelector[] = []; + for (const selector of selectors) { + const existingSelectorIndex = dedupedSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter); + if (existingSelectorIndex >= 0) { + dedupedSelectors.splice(existingSelectorIndex, 1); + } + dedupedSelectors.push(selector); + } + + return dedupedSelectors.map(selectorCandidate => { const selector = { ...selectorCandidate }; if (!selector.keyFilter) { throw new Error("Key filter cannot be null or empty."); diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index de3b6da3..5da5fd18 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -3,31 +3,17 @@ import { AppConfigurationClientOptions } from "@azure/app-configuration"; import { AzureAppConfigurationKeyVaultOptions } from "./keyvault/AzureAppConfigurationKeyVaultOptions"; +import { SettingSelector } from "./types"; export const MaxRetries = 2; export const MaxRetryDelayInMs = 60000; export interface AzureAppConfigurationOptions { /** - * Specify what key-values to include in the configuration provider. include multiple sets of key-values - * - * @property keyFilter: - * The key filter to apply when querying Azure App Configuration for key-values. - * An asterisk `*` can be added to the end to return all key-values whose key begins with the key filter. - * e.g. key filter `abc*` returns all key-values whose key starts with `abc`. - * A comma `,` can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. - * Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. - * E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. - * For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\). - * e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. - * - * @property labelFilter: - * The label filter to apply when querying Azure App Configuration for key-values. - * By default, the "null label" will be used, matching key-values without a label. - * The characters asterisk `*` and comma `,` are not supported. - * Backslash `\` character is reserved and must be escaped using another backslash `\`. + * Specify what key-values to include in the configuration provider. + * If no selectors are specified then all key-values with no label will be included. */ - selectors?: { keyFilter: string, labelFilter?: string }[]; + selectors?: SettingSelector[]; /** * Specifies prefixes to be trimmed from the keys of all key-values retrieved from Azure App Configuration. diff --git a/src/KeyFilter.ts b/src/KeyFilter.ts deleted file mode 100644 index ff7366ea..00000000 --- a/src/KeyFilter.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export enum KeyFilter { - Any = "*" -} \ No newline at end of file diff --git a/src/LabelFilter.ts b/src/LabelFilter.ts deleted file mode 100644 index ebb8361a..00000000 --- a/src/LabelFilter.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export enum LabelFilter { - Null = "\0" -} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b2dc467b..524dbec7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,5 +3,4 @@ export { load } from "./load"; export { AzureAppConfiguration } from "./AzureAppConfiguration"; -export { KeyFilter } from "./KeyFilter"; -export { LabelFilter } from "./LabelFilter"; \ No newline at end of file +export { KeyFilter, LabelFilter } from "./types"; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..0b51f905 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * SettingSelector is used to select key-values from Azure App Configuration. + * It is used to filter key-values based on keys and labels. + * + * @property keyFilter: + * The key filter to apply when querying Azure App Configuration for key-values. + * An asterisk `*` can be added to the end to return all key-values whose key begins with the key filter. + * e.g. key filter `abc*` returns all key-values whose key starts with `abc`. + * A comma `,` can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. + * Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. + * E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. + * For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\). + * e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. + * + * @property labelFilter: + * The label filter to apply when querying Azure App Configuration for key-values. + * By default, the "null label" will be used, matching key-values without a label. + * The characters asterisk `*` and comma `,` are not supported. + * Backslash `\` character is reserved and must be escaped using another backslash `\`. + */ +export type SettingSelector = { keyFilter: string, labelFilter?: string }; + +/** + * KeyFilter is used to filter key-values based on keys. + * + * @property Any: + * Matches all key-values. + */ +export enum KeyFilter { + Any = "*" +} + +/** + * LabelFilter is used to filter key-values based on labels. + * + * @property Null: + * Matches key-values without a label. + */ +export enum LabelFilter { + Null = "\0" +} diff --git a/test/load.test.ts b/test/load.test.ts index 3e3d4c10..a0b89691 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -151,4 +151,24 @@ describe("load", function () { expect(settings.has("TestKey")).eq(true); expect(settings.get("TestKey")).eq("TestValueForProd"); }); -}) + + it("should dedup 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.has("TestKey")).eq(true); + expect(settings.get("TestKey")).eq("TestValueForProd"); + }); + +});