Skip to content

Support load snapshot #140

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1f41e0d
support snapshot
zhiyuanliang-ms Dec 19, 2024
a331e98
add testcase
zhiyuanliang-ms Dec 20, 2024
1105531
Merge branch 'preview' of https://github.com/Azure/AppConfiguration-J…
zhiyuanliang-ms Dec 20, 2024
7d98b58
add testcase
zhiyuanliang-ms Dec 20, 2024
9bffa88
fix lint
zhiyuanliang-ms Dec 20, 2024
28c2d0e
update
zhiyuanliang-ms Dec 24, 2024
92d6531
Merge pull request #162 from Azure/merge-main-to-preview
zhiyuanliang-ms Feb 12, 2025
501cfe4
Merge branch 'preview' of https://github.com/Azure/AppConfiguration-J…
zhiyuanliang-ms Feb 12, 2025
2a09f46
Merge branch 'zhiyuanliang/select-snapshot' of https://github.com/Azu…
zhiyuanliang-ms Feb 12, 2025
fed19f8
Merge branch 'main' into zhiyuanliang/select-snapshot
zhiyuanliang-ms Feb 20, 2025
071215e
Merge branch 'main' into zhiyuanliang/select-snapshot
zhiyuanliang-ms Apr 2, 2025
160f30a
Merge branch 'main' into zhiyuanliang/select-snapshot
zhiyuanliang-ms Apr 23, 2025
994c10e
update test
zhiyuanliang-ms Apr 23, 2025
bda172e
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms Apr 29, 2025
cad828a
update testcase
zhiyuanliang-ms Apr 29, 2025
667f721
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms May 13, 2025
855e909
add more testcases
zhiyuanliang-ms May 19, 2025
030d72d
update
zhiyuanliang-ms May 19, 2025
381d833
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms May 19, 2025
da2845d
update error type
zhiyuanliang-ms May 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 110 additions & 33 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag, isSecretReference } from "@azure/app-configuration";
import {
AppConfigurationClient,
ConfigurationSetting,
ConfigurationSettingId,
GetConfigurationSettingOptions,
GetConfigurationSettingResponse,
ListConfigurationSettingsOptions,
featureFlagPrefix,
isFeatureFlag,
isSecretReference,
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";
Expand Down Expand Up @@ -29,7 +42,14 @@ import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } fro
import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.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 { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js";
import { KeyFilter, LabelFilter, SettingSelector } from "./types.js";
Expand Down Expand Up @@ -453,26 +473,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 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'.`);
}
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) {
Expand Down Expand Up @@ -644,6 +687,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise<boolean> {
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,
Expand Down Expand Up @@ -695,6 +741,29 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
return response;
}

async #getSnapshot(snapshotName: string, customOptions?: GetSnapshotOptions): Promise<GetSnapshotResponse | undefined> {
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;
}

// Only operations related to Azure App Configuration should be executed with failover policy.
async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise<any>): Promise<any> {
let clientWrappers = await this.#clientManager.getClients();
Expand Down Expand Up @@ -838,11 +907,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);
}
Expand All @@ -851,14 +920,20 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {

return uniqueSelectors.map(selectorCandidate => {
const selector = { ...selectorCandidate };
if (!selector.keyFilter) {
throw new ArgumentError("Key filter cannot be null or empty.");
}
if (!selector.labelFilter) {
selector.labelFilter = LabelFilter.Null;
}
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
if (selector.snapshotName) {
if (selector.keyFilter || selector.labelFilter) {
throw new ArgumentError("Key or label filter should not be used for a snapshot.");
}
} else {
if (!selector.keyFilter) {
throw new ArgumentError("Key filter cannot be null or empty.");
}
if (!selector.labelFilter) {
selector.labelFilter = LabelFilter.Null;
}
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
}
}
return selector;
});
Expand All @@ -869,7 +944,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[] {
Expand All @@ -878,7 +953,9 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
return [{ keyFilter: `${featureFlagPrefix}${KeyFilter.Any}`, labelFilter: LabelFilter.Null }];
}
selectors.forEach(selector => {
selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`;
if (selector.keyFilter) {
selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`;
}
});
return getValidSelectors(selectors);
return getValidSettingSelectors(selectors);
}
47 changes: 31 additions & 16 deletions src/requestTracing/utils.ts
Original file line number Diff line number Diff line change
@@ -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 { AIConfigurationTracingOptions } from "./AIConfigurationTracingOptions.js";
Expand Down Expand Up @@ -52,15 +53,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);
}

Expand All @@ -70,20 +63,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<T extends OperationOptions>(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
Expand Down Expand Up @@ -227,4 +243,3 @@ export function isWebWorker() {

return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected;
}

11 changes: 10 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
};

/**
Expand Down
23 changes: 22 additions & 1 deletion test/featureFlag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as chai from "chai";
import * as chaiAsPromised from "chai-as-promised";
import { featureFlagContentType } from "@azure/app-configuration";
import { load } from "./exportedApi.js";
import { MAX_TIME_OUT, createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js";
import { MAX_TIME_OUT, mockAppConfigurationClientGetSnapshot, mockAppConfigurationClientListConfigurationSettingsForSnapshot, createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js";
chai.use(chaiAsPromised);
const expect = chai.expect;

Expand Down Expand Up @@ -337,4 +337,25 @@ describe("feature flags", function () {
expect(featureFlag.telemetry.metadata.ETag).equals("ETag");
expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`);
});

it("should load feature flags from snapshot", async () => {
const snapshotName = "Test";
mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"});
mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[createMockedFeatureFlag("TestFeature", { enabled: true })]]);
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
featureFlagOptions: {
enabled: true,
selectors: [ { snapshotName: snapshotName } ]
}
});
expect(settings).not.undefined;
expect(settings.get("feature_management")).not.undefined;
const featureFlags = settings.get<any>("feature_management").feature_flags;
expect((featureFlags as []).length).equals(1);
const featureFlag = featureFlags[0];
expect(featureFlag.id).equals("TestFeature");
expect(featureFlag.enabled).equals(true);
restoreMocks();
});
});
Loading