From de7781ada650fc94d413a61112b4945eb0c585e7 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 24 Sep 2025 17:53:40 +0800 Subject: [PATCH 1/5] wip --- src/appConfigurationImpl.ts | 2 +- src/common/contentType.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index e46bd110..4bfc359d 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -41,7 +41,7 @@ import { CLIENT_FILTERS_KEY_NAME } from "./featureManagement/constants.js"; import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } from "./requestTracing/constants.js"; -import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js"; +import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType, isSnapshotReferenceContentType } from "./common/contentType.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/keyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/refreshTimer.js"; import { diff --git a/src/common/contentType.ts b/src/common/contentType.ts index 4891f425..cbd4e35e 100644 --- a/src/common/contentType.ts +++ b/src/common/contentType.ts @@ -60,3 +60,12 @@ export function isSecretReferenceContentType(contentType: ContentType | undefine } return mediaType === secretReferenceContentType; } + +export function isSnapshotReferenceContentType(contentType: ContentType | undefined): boolean { + const mediaType = contentType?.mediaType; + if (!mediaType) { + return false; + } + // TODO: replace with constant when available in Azure SDK + return mediaType === "application/json; profile=\"https://azconfig.io/mime-profiles/snapshot-ref\"; charset=utf-8"; +} From d8dee3b6d8dab4c18725ff1a26826eca7e639dc4 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Thu, 2 Oct 2025 15:34:18 +0800 Subject: [PATCH 2/5] support snapshot reference --- src/appConfigurationImpl.ts | 66 ++++++++++++++++++++++++++++----- src/requestTracing/constants.ts | 1 + src/requestTracing/utils.ts | 7 +++- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 4bfc359d..0a0f228e 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -82,6 +82,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #featureFlagTracing: FeatureFlagTracingOptions | undefined; #fmVersion: string | undefined; #aiConfigurationTracing: AIConfigurationTracingOptions | undefined; + #useSnapshotReference: boolean = false; // Refresh #refreshInProgress: boolean = false; @@ -213,7 +214,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { isFailoverRequest: this.#isFailoverRequest, featureFlagTracing: this.#featureFlagTracing, fmVersion: this.#fmVersion, - aiConfigurationTracing: this.#aiConfigurationTracing + aiConfigurationTracing: this.#aiConfigurationTracing, + useSnapshotReference: this.#useSnapshotReference }; } @@ -504,17 +506,26 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { selector.pageWatchers = pageWatchers; settings = items; } else { // snapshot selector - const snapshot = await this.#getSnapshot(selector.snapshotName); - if (snapshot === undefined) { - throw new InvalidOperationError(`Could not find snapshot with name ${selector.snapshotName}.`); - } - if (snapshot.compositionType != KnownSnapshotComposition.Key) { - throw new InvalidOperationError(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`); - } - settings = await this.#listConfigurationSettingsForSnapshot(selector.snapshotName); + settings = await this.#loadConfigurationSettingsFromSnapshot(selector.snapshotName); } for (const setting of settings) { + if (isSnapshotReference(setting) && !loadFeatureFlag) { + this.#useSnapshotReference = true; + + // TODO: When SDK supports snapshot reference, use the helper method from SDK. + const snapshotName = parseSnapshotReference(setting).value.snapshotName; + const settingsFromSnapshot = await this.#loadConfigurationSettingsFromSnapshot(snapshotName); + + for (const snapshotSetting of settingsFromSnapshot) { + if (!isFeatureFlag(snapshotSetting)) { + // Feature flags inside snapshot are ignored. This is consistent the behavior that key value selectors ignore feature flags. + loadedSettings.set(snapshotSetting.key, snapshotSetting); + } + } + continue; + } + if (loadFeatureFlag === isFeatureFlag(setting)) { loadedSettings.set(setting.key, setting); } @@ -575,6 +586,18 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } + async #loadConfigurationSettingsFromSnapshot(snapshotName: string): Promise { + const snapshot = await this.#getSnapshot(snapshotName); + if (snapshot === undefined) { + throw new InvalidOperationError(`Could not find snapshot with name ${snapshotName}.`); + } + if (snapshot.compositionType != KnownSnapshotComposition.Key) { + throw new InvalidOperationError(`Composition type for the selected snapshot with name ${snapshotName} must be 'key'.`); + } + const settings: ConfigurationSetting[] = await this.#listConfigurationSettingsForSnapshot(snapshotName); + return settings; + } + /** * Clears all existing key-values in the local configuration except feature flags. */ @@ -1071,3 +1094,28 @@ function validateTagFilters(tagFilters: string[]): void { } } } + +// TODO: Temporary workaround until SDK supports snapshot reference +const snapshotReferenceContentType = "application/json; profile=\"https://azconfig.io/mime-profiles/snapshot-ref\"; charset=utf-8"; + +interface JsonSnapshotReferenceValue { + snapshot_name: string; +} + +function isSnapshotReference(setting: ConfigurationSetting): + setting is ConfigurationSetting & Required> { + return (setting && setting.contentType === snapshotReferenceContentType && typeof setting.value === "string"); +} + +function parseSnapshotReference(setting: ConfigurationSetting) { + if (!isSnapshotReference(setting)) { + throw new Error(`Invalid snapshot reference: ${setting}`); + } + const jsonSnapshotReferenceValue = JSON.parse(setting.value) as JsonSnapshotReferenceValue; + + const snapshotReference = { + ...setting, + value: { snapshotName: jsonSnapshotReferenceValue.snapshot_name }, + }; + return snapshotReference; +} diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index 6f9311b4..2695d0b7 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -51,6 +51,7 @@ export const REPLICA_COUNT_KEY = "ReplicaCount"; export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; export const KEY_VAULT_REFRESH_CONFIGURED_TAG = "RefreshesKeyVault"; export const FAILOVER_REQUEST_TAG = "Failover"; +export const SNAPSHOT_REFERENCE_TAG = "SnapshotRef"; // Compact feature tags export const FEATURES_KEY = "Features"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index e9949505..3b1337e3 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -41,7 +41,8 @@ import { FM_VERSION_KEY, DELIMITER, AI_CONFIGURATION_TAG, - AI_CHAT_COMPLETION_CONFIGURATION_TAG + AI_CHAT_COMPLETION_CONFIGURATION_TAG, + SNAPSHOT_REFERENCE_TAG } from "./constants.js"; export interface RequestTracingOptions { @@ -53,6 +54,7 @@ export interface RequestTracingOptions { featureFlagTracing: FeatureFlagTracingOptions | undefined; fmVersion: string | undefined; aiConfigurationTracing: AIConfigurationTracingOptions | undefined; + useSnapshotReference: boolean; } // Utils @@ -195,6 +197,9 @@ function createFeaturesString(requestTracingOptions: RequestTracingOptions): str if (requestTracingOptions.aiConfigurationTracing?.usesAIChatCompletionConfiguration) { tags.push(AI_CHAT_COMPLETION_CONFIGURATION_TAG); } + if (requestTracingOptions.useSnapshotReference) { + tags.push(SNAPSHOT_REFERENCE_TAG); + } return tags.join(DELIMITER); } From c896e1a8c7004bfae5e1f3c34502d3a64c3034d0 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 2 Oct 2025 20:58:32 +0800 Subject: [PATCH 3/5] add test --- src/appConfigurationImpl.ts | 2 +- src/common/contentType.ts | 9 --- test/featureFlag.test.ts | 10 +++- test/load.test.ts | 12 ++-- test/snapshotReference.test.ts | 104 +++++++++++++++++++++++++++++++++ test/utils/testHelper.ts | 29 ++++++--- vitest.browser.config.ts | 4 +- 7 files changed, 143 insertions(+), 27 deletions(-) create mode 100644 test/snapshotReference.test.ts diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 0a0f228e..f0797ba3 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -41,7 +41,7 @@ import { CLIENT_FILTERS_KEY_NAME } from "./featureManagement/constants.js"; import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } from "./requestTracing/constants.js"; -import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType, isSnapshotReferenceContentType } from "./common/contentType.js"; +import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/keyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/refreshTimer.js"; import { diff --git a/src/common/contentType.ts b/src/common/contentType.ts index cbd4e35e..4891f425 100644 --- a/src/common/contentType.ts +++ b/src/common/contentType.ts @@ -60,12 +60,3 @@ export function isSecretReferenceContentType(contentType: ContentType | undefine } return mediaType === secretReferenceContentType; } - -export function isSnapshotReferenceContentType(contentType: ContentType | undefined): boolean { - const mediaType = contentType?.mediaType; - if (!mediaType) { - return false; - } - // TODO: replace with constant when available in Azure SDK - return mediaType === "application/json; profile=\"https://azconfig.io/mime-profiles/snapshot-ref\"; charset=utf-8"; -} diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index fb7b3da1..0a89b745 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -415,8 +415,14 @@ describe("feature flags", function () { it("should load feature flags from snapshot", async () => { const snapshotName = "Test"; - mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); - mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[createMockedFeatureFlag("TestFeature", { enabled: true })]]); + const snapshotResponses = new Map([ + [snapshotName, { compositionType: "key" }] + ]); + const snapshotKVs = new Map([ + [snapshotName, [[createMockedFeatureFlag("TestFeature", { enabled: true })]]] + ]); + mockAppConfigurationClientGetSnapshot(snapshotResponses); + mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotKVs); const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { featureFlagOptions: { diff --git a/test/load.test.ts b/test/load.test.ts index 91a74990..d1f109e1 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -581,8 +581,14 @@ describe("load", function () { it("should load key values from snapshot", async () => { const snapshotName = "Test"; - mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); - mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)]); + const snapshotResponses = new Map([ + [snapshotName, { compositionType: "key" }] + ]); + const snapshotKVs = new Map([ + [snapshotName, [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)]]] + ); + mockAppConfigurationClientGetSnapshot(snapshotResponses); + mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotKVs); const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { selectors: [{ @@ -590,9 +596,7 @@ describe("load", function () { }] }); expect(settings).not.undefined; - expect(settings).not.undefined; expect(settings.get("TestKey")).eq("TestValue"); - restoreMocks(); }); }); /* eslint-enable @typescript-eslint/no-unused-expressions */ diff --git a/test/snapshotReference.test.ts b/test/snapshotReference.test.ts new file mode 100644 index 00000000..38d7442f --- /dev/null +++ b/test/snapshotReference.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; +import { load } from "../src/index.js"; +import { + mockAppConfigurationClientListConfigurationSettings, + mockAppConfigurationClientGetSnapshot, + mockAppConfigurationClientListConfigurationSettingsForSnapshot, + restoreMocks, + createMockedConnectionString, + createMockedKeyValue, + createMockedSnapshotReference, + createMockedFeatureFlag, + sleepInMs +} from "./utils/testHelper.js"; +import * as uuid from "uuid"; + +const mockedKVs = [{ + key: "TestKey1", + value: "Value1", +}, { + key: "TestKey2", + value: "Value2", +} +].map(createMockedKeyValue); + +mockedKVs.push(createMockedSnapshotReference("TestSnapshotRef", "TestSnapshot1")); + +// TestSnapshot1 +const snapshot1 = [{ + key: "TestKey1", + value: "Value1 in snapshot1", +} +].map(createMockedKeyValue); +const testFeatureFlag = createMockedFeatureFlag("TestFeatureFlag"); +snapshot1.push(testFeatureFlag); + +// TestSnapshot2 +const snapshot2 = [{ + key: "TestKey1", + value: "Value1 in snapshot2", +} +].map(createMockedKeyValue); + +describe("snapshot reference", function () { + + beforeEach(() => { + const snapshotResponses = new Map([ + ["TestSnapshot1", { compositionType: "key" }], + ["TestSnapshot2", { compositionType: "key" }]] + ); + const snapshotKVs = new Map([ + ["TestSnapshot1", [snapshot1]], + ["TestSnapshot2", [snapshot2]]] + ); + mockAppConfigurationClientGetSnapshot(snapshotResponses); + mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotKVs); + mockAppConfigurationClientListConfigurationSettings([mockedKVs]); + }); + + afterEach(() => { + restoreMocks(); + }); + + it("should resolve snapshot reference", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + expect(settings.get("TestKey1")).eq("Value1 in snapshot1"); + + // it should ignore feature flags in snapshot + expect(settings.get(testFeatureFlag.key)).to.be.undefined; + expect(settings.get("feature_management")).to.be.undefined; + + // it should not load the snapshot reference key + expect(settings.get("TestSnapshotRef")).to.be.undefined; + }); + + it("should refresh when snapshot reference changes", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + expect(settings.get("TestKey1")).eq("Value1 in snapshot1"); + + const setting = mockedKVs.find(kv => kv.key === "TestSnapshotRef"); + setting!.value = "{\"snapshot_name\":\"TestSnapshot2\"}"; + setting!.etag = uuid.v4(); + + await sleepInMs(2 * 1000 + 1); + + await settings.refresh(); + + expect(settings.get("TestKey1")).eq("Value1 in snapshot2"); + }); + +}); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 1da810bd..778979ba 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, ConfigurationSetting, featureFlagContentType } from "@azure/app-configuration"; +import { AppConfigurationClient, ConfigurationSetting, featureFlagContentType, secretReferenceContentType } from "@azure/app-configuration"; import { ClientSecretCredential } from "@azure/identity"; import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; import * as uuid from "uuid"; @@ -182,29 +182,29 @@ function mockAppConfigurationClientGetConfigurationSetting(kvList: any[], custom }); } -function mockAppConfigurationClientGetSnapshot(snapshotName: string, mockedResponse: any, customCallback?: (options) => any) { +function mockAppConfigurationClientGetSnapshot(snapshotResponses: Map, customCallback?: (options) => any) { sinon.stub(AppConfigurationClient.prototype, "getSnapshot").callsFake((name, options) => { if (customCallback) { customCallback(options); } - if (name === snapshotName) { - return mockedResponse; + if (snapshotResponses.has(name)) { + return snapshotResponses.get(name); } else { throw new RestError("", { statusCode: 404 }); } }); } -function mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName: string, pages: ConfigurationSetting[][], customCallback?: (options) => any) { +function mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotResponses: Map, customCallback?: (options) => any) { sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettingsForSnapshot").callsFake((name, listOptions) => { if (customCallback) { customCallback(listOptions); } - if (name === snapshotName) { - const kvs = _filterKVs(pages.flat(), listOptions); - return getMockedIterator(pages, kvs, listOptions); + if (snapshotResponses.has(name)) { + const kvs = _filterKVs(snapshotResponses.get(name)!.flat(), listOptions); + return getMockedIterator(snapshotResponses.get(name)!, kvs, listOptions); } else { throw new RestError("", { statusCode: 404 }); } @@ -252,7 +252,7 @@ const createMockedKeyVaultReference = (key: string, vaultUri: string): Configura // https://${vaultName}.vault.azure.net/secrets/${secretName} value: `{"uri":"${vaultUri}"}`, key, - contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", + contentType: secretReferenceContentType, lastModified: new Date(), tags: {}, etag: uuid.v4(), @@ -296,6 +296,16 @@ const createMockedFeatureFlag = (name: string, flagProps?: any, props?: any) => isReadOnly: false }, props)); +const createMockedSnapshotReference = (key: string, snapshotName: string): ConfigurationSetting => ({ + value: `{"snapshot_name":"${snapshotName}"}`, + key, + contentType: "application/json; profile=\"https://azconfig.io/mime-profiles/snapshot-ref\"; charset=utf-8", + lastModified: new Date(), + tags: {}, + etag: uuid.v4(), + isReadOnly: false, +}); + class HttpRequestHeadersPolicy { headers: any; name: string; @@ -328,6 +338,7 @@ export { createMockedJsonKeyValue, createMockedKeyValue, createMockedFeatureFlag, + createMockedSnapshotReference, sleepInMs, HttpRequestHeadersPolicy diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 011e0f93..7c51088a 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ { browser: "chromium" }, ], }, - include: ["out/esm/test/load.test.js", "out/esm/test/refresh.test.js", "out/esm/test/featureFlag.test.js", "out/esm/test/json.test.js", "out/esm/test/startup.test.js"], + include: ["out/esm/test/load.test.js", "out/esm/test/refresh.test.js", "out/esm/test/featureFlag.test.js", "out/esm/test/json.test.js", "out/esm/test/startup.test.js", "out/esm/test/snapshotReference.test.js"], testTimeout: 200_000, hookTimeout: 200_000, reporters: "default", @@ -18,4 +18,4 @@ export default defineConfig({ // Provide Mocha-style hooks as globals setupFiles: ["./vitest.setup.mjs"], }, -}); \ No newline at end of file +}); From 90d0727cff0320053d6847a4c30a4736e043c16e Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Sun, 7 Sep 2025 12:14:29 +0800 Subject: [PATCH 4/5] update lint rule --- eslint.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index f8b227e2..db60a023 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -53,6 +53,7 @@ export default defineConfig([globalIgnores([ }], "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-require-imports": "off", "eol-last": ["error", "always"], "no-trailing-spaces": "error", "space-before-blocks": ["error", "always"], From a2d2782ae980848114503c09826a2e23eab7d173 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Fri, 14 Nov 2025 00:06:03 +0800 Subject: [PATCH 5/5] update --- eslint.config.mjs | 1 - package-lock.json | 10 +++++----- package.json | 2 +- src/appConfigurationImpl.ts | 39 ++++++++++--------------------------- src/common/errors.ts | 7 +++++++ 5 files changed, 23 insertions(+), 36 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index db60a023..f8b227e2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -53,7 +53,6 @@ export default defineConfig([globalIgnores([ }], "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-require-imports": "off", "eol-last": ["error", "always"], "no-trailing-spaces": "error", "space-before-blocks": ["error", "always"], diff --git a/package-lock.json b/package-lock.json index 1b99f9c3..07cb7afc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.2.0", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.9.0", + "@azure/app-configuration": "^1.10.0", "@azure/core-rest-pipeline": "^1.6.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0", @@ -74,9 +74,9 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.9.0.tgz", - "integrity": "sha512-X0AVDQygL4AGLtplLYW+W0QakJpJ417sQldOacqwcBQ882tAPdUVs6V3mZ4jUjwVsgr+dV1v9zMmijvsp6XBxA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.10.0.tgz", + "integrity": "sha512-WA5Q70uGQfn6KAgh5ilYuLT8kwkYg5gr6qXH3HGx7OioNDkM6HRPHDWyuAk/G9+20Y0nt7jKTJEGF7NrMIYb+A==", "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", @@ -92,7 +92,7 @@ "tslib": "^2.2.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/core-auth": { diff --git a/package.json b/package.json index b61d93b3..8e56b21f 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "playwright": "^1.55.0" }, "dependencies": { - "@azure/app-configuration": "^1.9.0", + "@azure/app-configuration": "^1.10.0", "@azure/core-rest-pipeline": "^1.6.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0", diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index f0797ba3..5d895081 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -11,6 +11,9 @@ import { featureFlagPrefix, isFeatureFlag, isSecretReference, + isSnapshotReference, + parseSnapshotReference, + SnapshotReferenceValue, GetSnapshotOptions, ListConfigurationSettingsForSnapshotOptions, GetSnapshotResponse, @@ -57,7 +60,7 @@ import { AIConfigurationTracingOptions } from "./requestTracing/aiConfigurationT import { KeyFilter, LabelFilter, SettingWatcher, SettingSelector, PagedSettingsWatcher, WatchedSetting } from "./types.js"; import { ConfigurationClientManager } from "./configurationClientManager.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; -import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js"; +import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError, SnapshotReferenceError } from "./common/errors.js"; import { ErrorMessages } from "./common/errorMessages.js"; const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds @@ -513,8 +516,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (isSnapshotReference(setting) && !loadFeatureFlag) { this.#useSnapshotReference = true; - // TODO: When SDK supports snapshot reference, use the helper method from SDK. - const snapshotName = parseSnapshotReference(setting).value.snapshotName; + const snapshotRef: ConfigurationSetting = parseSnapshotReference(setting); + const snapshotName = snapshotRef.value.snapshotName; + if (!snapshotName) { + throw new SnapshotReferenceError(`Invalid format for Snapshot reference setting '${setting.key}'.`); + } const settingsFromSnapshot = await this.#loadConfigurationSettingsFromSnapshot(snapshotName); for (const snapshotSetting of settingsFromSnapshot) { @@ -589,7 +595,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { async #loadConfigurationSettingsFromSnapshot(snapshotName: string): Promise { const snapshot = await this.#getSnapshot(snapshotName); if (snapshot === undefined) { - throw new InvalidOperationError(`Could not find snapshot with name ${snapshotName}.`); + return []; // treat non-existing snapshot as empty } if (snapshot.compositionType != KnownSnapshotComposition.Key) { throw new InvalidOperationError(`Composition type for the selected snapshot with name ${snapshotName} must be 'key'.`); @@ -1094,28 +1100,3 @@ function validateTagFilters(tagFilters: string[]): void { } } } - -// TODO: Temporary workaround until SDK supports snapshot reference -const snapshotReferenceContentType = "application/json; profile=\"https://azconfig.io/mime-profiles/snapshot-ref\"; charset=utf-8"; - -interface JsonSnapshotReferenceValue { - snapshot_name: string; -} - -function isSnapshotReference(setting: ConfigurationSetting): - setting is ConfigurationSetting & Required> { - return (setting && setting.contentType === snapshotReferenceContentType && typeof setting.value === "string"); -} - -function parseSnapshotReference(setting: ConfigurationSetting) { - if (!isSnapshotReference(setting)) { - throw new Error(`Invalid snapshot reference: ${setting}`); - } - const jsonSnapshotReferenceValue = JSON.parse(setting.value) as JsonSnapshotReferenceValue; - - const snapshotReference = { - ...setting, - value: { snapshotName: jsonSnapshotReferenceValue.snapshot_name }, - }; - return snapshotReference; -} diff --git a/src/common/errors.ts b/src/common/errors.ts index bd4f5adf..4984023f 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -33,6 +33,13 @@ export class KeyVaultReferenceError extends Error { } } +export class SnapshotReferenceError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "SnapshotReferenceError"; + } +} + export function isFailoverableError(error: any): boolean { if (!isRestError(error)) { return false;