diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 916ece4..2ab8d64 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -1,7 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag } from "@azure/app-configuration"; +import { + AppConfigurationClient, + ConfigurationSetting, + ConfigurationSettingId, + GetConfigurationSettingOptions, + GetConfigurationSettingResponse, + ListConfigurationSettingsOptions, + featureFlagPrefix, + isFeatureFlag, + GetSnapshotOptions, + GetSnapshotResponse, + KnownSnapshotComposition +} from "@azure/app-configuration"; import { isRestError } from "@azure/core-rest-pipeline"; import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; @@ -35,7 +47,14 @@ import { } from "./featureManagement/constants.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/RefreshTimer.js"; -import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; +import { + RequestTracingOptions, + getConfigurationSettingWithTrace, + listConfigurationSettingsWithTrace, + getSnapshotWithTrace, + listConfigurationSettingsForSnapshotWithTrace, + requestTracingEnabled +} from "./requestTracing/utils.js"; import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; @@ -363,26 +382,49 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { ); for (const selector of selectorsToUpdate) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter - }; - - const pageEtags: string[] = []; - const pageIterator = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - client, - listOptions - ).byPage(); - for await (const page of pageIterator) { - pageEtags.push(page.etag ?? ""); - for (const setting of page.items) { - if (loadFeatureFlag === isFeatureFlag(setting)) { - loadedSettings.push(setting); + if (selector.snapshotName === undefined) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: selector.keyFilter, + labelFilter: selector.labelFilter + }; + const pageEtags: string[] = []; + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + client, + listOptions + ).byPage(); + + for await (const page of pageIterator) { + pageEtags.push(page.etag ?? ""); + for (const setting of page.items) { + if (loadFeatureFlag === isFeatureFlag(setting)) { + loadedSettings.push(setting); + } + } + } + selector.pageEtags = pageEtags; + } else { // snapshot selector + const snapshot = await this.#getSnapshot(selector.snapshotName); + if (snapshot === undefined) { + throw new Error(`Could not find snapshot with name ${selector.snapshotName}.`); + } + if (snapshot.compositionType != KnownSnapshotComposition.Key) { + throw new Error(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`); + } + const pageIterator = listConfigurationSettingsForSnapshotWithTrace( + this.#requestTraceOptions, + client, + selector.snapshotName + ).byPage(); + + for await (const page of pageIterator) { + for (const setting of page.items) { + if (loadFeatureFlag === isFeatureFlag(setting)) { + loadedSettings.push(setting); + } } } } - selector.pageEtags = pageEtags; } if (loadFeatureFlag) { @@ -530,6 +572,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise { const funcToExecute = async (client) => { for (const selector of selectors) { + if (selector.snapshotName) { // skip snapshot selector + continue; + } const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, @@ -581,6 +626,29 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return response; } + async #getSnapshot(snapshotName: string, customOptions?: GetSnapshotOptions): Promise { + const funcToExecute = async (client) => { + return getSnapshotWithTrace( + this.#requestTraceOptions, + client, + snapshotName, + customOptions + ); + }; + + let response: GetSnapshotResponse | undefined; + try { + response = await this.#executeWithFailoverPolicy(funcToExecute); + } catch (error) { + if (isRestError(error) && error.statusCode === 404) { + response = undefined; + } else { + throw error; + } + } + return response; + } + async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { let clientWrappers = await this.#clientManager.getClients(); if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) { @@ -862,11 +930,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } -function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { - // below code deduplicates selectors by keyFilter and labelFilter, the latter selector wins +function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] { + // below code deduplicates selectors, the latter selector wins const uniqueSelectors: SettingSelector[] = []; for (const selector of selectors) { - const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter); + const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter && s.snapshotName === selector.snapshotName); if (existingSelectorIndex >= 0) { uniqueSelectors.splice(existingSelectorIndex, 1); } @@ -875,14 +943,20 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { return uniqueSelectors.map(selectorCandidate => { const selector = { ...selectorCandidate }; - if (!selector.keyFilter) { - throw new Error("Key filter cannot be null or empty."); - } - if (!selector.labelFilter) { - selector.labelFilter = LabelFilter.Null; - } - if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in label filters."); + if (selector.snapshotName) { + if (selector.keyFilter || selector.labelFilter) { + throw new Error("Key or label filter should not be used for a snapshot."); + } + } else { + if (!selector.keyFilter) { + throw new Error("Key filter cannot be null or empty."); + } + if (!selector.labelFilter) { + selector.labelFilter = LabelFilter.Null; + } + if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in label filters."); + } } return selector; }); @@ -893,7 +967,7 @@ function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelect // Default selector: key: *, label: \0 return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; } - return getValidSelectors(selectors); + return getValidSettingSelectors(selectors); } function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] { @@ -901,10 +975,11 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel // selectors must be explicitly provided. throw new Error("Feature flag selectors must be provided."); } else { - selectors.forEach(selector => { + const validSelectors = getValidSettingSelectors(selectors); + validSelectors.forEach(selector => { selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`; }); - return getValidSelectors(selectors); + return validSelectors; } } diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index b56c460..28f2c5f 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -1,7 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions } from "@azure/app-configuration"; +import { OperationOptions } from "@azure/core-client"; +import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions, GetSnapshotOptions, ListConfigurationSettingsForSnapshotOptions } from "@azure/app-configuration"; import { AzureAppConfigurationOptions } from "../AzureAppConfigurationOptions.js"; import { FeatureFlagTracingOptions } from "./FeatureFlagTracingOptions.js"; import { @@ -45,15 +46,7 @@ export function listConfigurationSettingsWithTrace( client: AppConfigurationClient, listOptions: ListConfigurationSettingsOptions ) { - const actualListOptions = { ...listOptions }; - if (requestTracingOptions.enabled) { - actualListOptions.requestOptions = { - customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) - } - }; - } - + const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions); return client.listConfigurationSettings(actualListOptions); } @@ -63,20 +56,43 @@ export function getConfigurationSettingWithTrace( configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions, ) { - const actualGetOptions = { ...getOptions }; + const actualGetOptions = applyRequestTracing(requestTracingOptions, getOptions); + return client.getConfigurationSetting(configurationSettingId, actualGetOptions); +} + +export function getSnapshotWithTrace( + requestTracingOptions: RequestTracingOptions, + client: AppConfigurationClient, + snapshotName: string, + getOptions?: GetSnapshotOptions +) { + const actualGetOptions = applyRequestTracing(requestTracingOptions, getOptions); + return client.getSnapshot(snapshotName, actualGetOptions); +} +export function listConfigurationSettingsForSnapshotWithTrace( + requestTracingOptions: RequestTracingOptions, + client: AppConfigurationClient, + snapshotName: string, + listOptions?: ListConfigurationSettingsForSnapshotOptions +) { + const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions); + return client.listConfigurationSettingsForSnapshot(snapshotName, actualListOptions); +} + +function applyRequestTracing(requestTracingOptions: RequestTracingOptions, operationOptions?: T) { + const actualOptions = { ...operationOptions }; if (requestTracingOptions.enabled) { - actualGetOptions.requestOptions = { + actualOptions.requestOptions = { customHeaders: { [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) } }; } - - return client.getConfigurationSetting(configurationSettingId, actualGetOptions); + return actualOptions; } -export function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string { +function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string { /* RequestType: 'Startup' during application starting up, 'Watch' after startup completed. Host: identify with defined envs @@ -200,4 +216,3 @@ export function isWebWorker() { return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected; } - diff --git a/src/types.ts b/src/types.ts index a818137..8ba8925 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,7 +17,7 @@ export type SettingSelector = { * For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\). * e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. */ - keyFilter: string, + keyFilter?: string, /** * The label filter to apply when querying Azure App Configuration for key-values. @@ -29,6 +29,15 @@ export type SettingSelector = { * @defaultValue `LabelFilter.Null`, matching key-values without a label. */ labelFilter?: string + + /** + * The name of snapshot to load from App Configuration. + * + * @remarks + * Snapshot is a set of key-values selected from the App Configuration store based on the composition type and filters. Once created, it is stored as an immutable entity that can be referenced by name. + * If snapshot name is used in a selector, no key and label filter should be used for it. Otherwise, an exception will be thrown. + */ + snapshotName?: string }; /** diff --git a/test/load.test.ts b/test/load.test.ts index d36a331..b0f3998 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi.js"; -import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetSnapshot, mockAppConfigurationClientListConfigurationSettingsForSnapshot, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; const mockedKVs = [{ key: "app.settings.fontColor", @@ -418,4 +418,32 @@ describe("load", function () { settings.constructConfigurationObject({ separator: "%" }); }).to.throw("Invalid separator '%'. Supported values: '.', ',', ';', '-', '_', '__', '/', ':'."); }); + + it("should load from snapshot", async () => { + const snapshotName = "Test"; + mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); + mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)]); + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + snapshotName: snapshotName + }] + }); + expect(settings).not.undefined; + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("TestValue"); + restoreMocks(); + }); + + it("should throw error when snapshot composition type is not key", async () => { + const snapshotName = "Test"; + mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key_label"}); + const connectionString = createMockedConnectionString(); + expect(load(connectionString, { + selectors: [{ + snapshotName: snapshotName + }] + })).eventually.rejectedWith(`Composition type for the selected snapshot with name ${snapshotName} must be 'key'.`); + restoreMocks(); + }); }); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index a581269..bc816fe 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -162,6 +162,35 @@ function mockAppConfigurationClientGetConfigurationSetting(kvList, customCallbac }); } +function mockAppConfigurationClientGetSnapshot(snapshotName: string, mockedResponse: any, customCallback?: (options) => any) { + sinon.stub(AppConfigurationClient.prototype, "getSnapshot").callsFake((name, options) => { + if (customCallback) { + customCallback(options); + } + + if (name === snapshotName) { + return mockedResponse; + } else { + throw new RestError("", { statusCode: 404 }); + } + }); +} + +function mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName: string, pages: ConfigurationSetting[][], customCallback?: (options) => any) { + sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettingsForSnapshot").callsFake((name, listOptions) => { + if (customCallback) { + customCallback(listOptions); + } + + if (name === snapshotName) { + const kvs = _filterKVs(pages.flat(), listOptions); + return getMockedIterator(pages, kvs, listOptions); + } else { + throw new RestError("", { statusCode: 404 }); + } + }); +} + // uriValueList: [["", "value"], ...] function mockSecretClientGetSecret(uriValueList: [string, string][]) { const dict = new Map(); @@ -265,6 +294,8 @@ export { sinon, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, + mockAppConfigurationClientGetSnapshot, + mockAppConfigurationClientListConfigurationSettingsForSnapshot, mockAppConfigurationClientLoadBalanceMode, mockConfigurationManagerGetClients, mockSecretClientGetSecret,