Skip to content

Commit ddd19e0

Browse files
Support load snapshot (#140)
* support snapshot * add testcase * add testcase * fix lint * update * update test * update testcase * add more testcases * update * update error type
1 parent 9861d03 commit ddd19e0

File tree

6 files changed

+240
-52
lines changed

6 files changed

+240
-52
lines changed

src/AzureAppConfigurationImpl.ts

Lines changed: 110 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag, isSecretReference } from "@azure/app-configuration";
4+
import {
5+
AppConfigurationClient,
6+
ConfigurationSetting,
7+
ConfigurationSettingId,
8+
GetConfigurationSettingOptions,
9+
GetConfigurationSettingResponse,
10+
ListConfigurationSettingsOptions,
11+
featureFlagPrefix,
12+
isFeatureFlag,
13+
isSecretReference,
14+
GetSnapshotOptions,
15+
GetSnapshotResponse,
16+
KnownSnapshotComposition
17+
} from "@azure/app-configuration";
518
import { isRestError } from "@azure/core-rest-pipeline";
619
import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration.js";
720
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js";
@@ -29,7 +42,14 @@ import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } fro
2942
import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js";
3043
import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js";
3144
import { RefreshTimer } from "./refresh/RefreshTimer.js";
32-
import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js";
45+
import {
46+
RequestTracingOptions,
47+
getConfigurationSettingWithTrace,
48+
listConfigurationSettingsWithTrace,
49+
getSnapshotWithTrace,
50+
listConfigurationSettingsForSnapshotWithTrace,
51+
requestTracingEnabled
52+
} from "./requestTracing/utils.js";
3353
import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js";
3454
import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js";
3555
import { KeyFilter, LabelFilter, SettingSelector } from "./types.js";
@@ -453,26 +473,49 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
453473
);
454474

455475
for (const selector of selectorsToUpdate) {
456-
const listOptions: ListConfigurationSettingsOptions = {
457-
keyFilter: selector.keyFilter,
458-
labelFilter: selector.labelFilter
459-
};
460-
461-
const pageEtags: string[] = [];
462-
const pageIterator = listConfigurationSettingsWithTrace(
463-
this.#requestTraceOptions,
464-
client,
465-
listOptions
466-
).byPage();
467-
for await (const page of pageIterator) {
468-
pageEtags.push(page.etag ?? "");
469-
for (const setting of page.items) {
470-
if (loadFeatureFlag === isFeatureFlag(setting)) {
471-
loadedSettings.push(setting);
476+
if (selector.snapshotName === undefined) {
477+
const listOptions: ListConfigurationSettingsOptions = {
478+
keyFilter: selector.keyFilter,
479+
labelFilter: selector.labelFilter
480+
};
481+
const pageEtags: string[] = [];
482+
const pageIterator = listConfigurationSettingsWithTrace(
483+
this.#requestTraceOptions,
484+
client,
485+
listOptions
486+
).byPage();
487+
488+
for await (const page of pageIterator) {
489+
pageEtags.push(page.etag ?? "");
490+
for (const setting of page.items) {
491+
if (loadFeatureFlag === isFeatureFlag(setting)) {
492+
loadedSettings.push(setting);
493+
}
494+
}
495+
}
496+
selector.pageEtags = pageEtags;
497+
} else { // snapshot selector
498+
const snapshot = await this.#getSnapshot(selector.snapshotName);
499+
if (snapshot === undefined) {
500+
throw new InvalidOperationError(`Could not find snapshot with name ${selector.snapshotName}.`);
501+
}
502+
if (snapshot.compositionType != KnownSnapshotComposition.Key) {
503+
throw new InvalidOperationError(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`);
504+
}
505+
const pageIterator = listConfigurationSettingsForSnapshotWithTrace(
506+
this.#requestTraceOptions,
507+
client,
508+
selector.snapshotName
509+
).byPage();
510+
511+
for await (const page of pageIterator) {
512+
for (const setting of page.items) {
513+
if (loadFeatureFlag === isFeatureFlag(setting)) {
514+
loadedSettings.push(setting);
515+
}
472516
}
473517
}
474518
}
475-
selector.pageEtags = pageEtags;
476519
}
477520

478521
if (loadFeatureFlag) {
@@ -644,6 +687,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
644687
async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise<boolean> {
645688
const funcToExecute = async (client) => {
646689
for (const selector of selectors) {
690+
if (selector.snapshotName) { // skip snapshot selector
691+
continue;
692+
}
647693
const listOptions: ListConfigurationSettingsOptions = {
648694
keyFilter: selector.keyFilter,
649695
labelFilter: selector.labelFilter,
@@ -695,6 +741,29 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
695741
return response;
696742
}
697743

744+
async #getSnapshot(snapshotName: string, customOptions?: GetSnapshotOptions): Promise<GetSnapshotResponse | undefined> {
745+
const funcToExecute = async (client) => {
746+
return getSnapshotWithTrace(
747+
this.#requestTraceOptions,
748+
client,
749+
snapshotName,
750+
customOptions
751+
);
752+
};
753+
754+
let response: GetSnapshotResponse | undefined;
755+
try {
756+
response = await this.#executeWithFailoverPolicy(funcToExecute);
757+
} catch (error) {
758+
if (isRestError(error) && error.statusCode === 404) {
759+
response = undefined;
760+
} else {
761+
throw error;
762+
}
763+
}
764+
return response;
765+
}
766+
698767
// Only operations related to Azure App Configuration should be executed with failover policy.
699768
async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise<any>): Promise<any> {
700769
let clientWrappers = await this.#clientManager.getClients();
@@ -838,11 +907,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
838907
}
839908
}
840909

841-
function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {
842-
// below code deduplicates selectors by keyFilter and labelFilter, the latter selector wins
910+
function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] {
911+
// below code deduplicates selectors, the latter selector wins
843912
const uniqueSelectors: SettingSelector[] = [];
844913
for (const selector of selectors) {
845-
const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter);
914+
const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter && s.snapshotName === selector.snapshotName);
846915
if (existingSelectorIndex >= 0) {
847916
uniqueSelectors.splice(existingSelectorIndex, 1);
848917
}
@@ -851,14 +920,20 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {
851920

852921
return uniqueSelectors.map(selectorCandidate => {
853922
const selector = { ...selectorCandidate };
854-
if (!selector.keyFilter) {
855-
throw new ArgumentError("Key filter cannot be null or empty.");
856-
}
857-
if (!selector.labelFilter) {
858-
selector.labelFilter = LabelFilter.Null;
859-
}
860-
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
861-
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
923+
if (selector.snapshotName) {
924+
if (selector.keyFilter || selector.labelFilter) {
925+
throw new ArgumentError("Key or label filter should not be used for a snapshot.");
926+
}
927+
} else {
928+
if (!selector.keyFilter) {
929+
throw new ArgumentError("Key filter cannot be null or empty.");
930+
}
931+
if (!selector.labelFilter) {
932+
selector.labelFilter = LabelFilter.Null;
933+
}
934+
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
935+
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
936+
}
862937
}
863938
return selector;
864939
});
@@ -869,7 +944,7 @@ function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelect
869944
// Default selector: key: *, label: \0
870945
return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }];
871946
}
872-
return getValidSelectors(selectors);
947+
return getValidSettingSelectors(selectors);
873948
}
874949

875950
function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] {
@@ -878,7 +953,9 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
878953
return [{ keyFilter: `${featureFlagPrefix}${KeyFilter.Any}`, labelFilter: LabelFilter.Null }];
879954
}
880955
selectors.forEach(selector => {
881-
selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`;
956+
if (selector.keyFilter) {
957+
selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`;
958+
}
882959
});
883-
return getValidSelectors(selectors);
960+
return getValidSettingSelectors(selectors);
884961
}

src/requestTracing/utils.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions } from "@azure/app-configuration";
4+
import { OperationOptions } from "@azure/core-client";
5+
import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions, GetSnapshotOptions, ListConfigurationSettingsForSnapshotOptions } from "@azure/app-configuration";
56
import { AzureAppConfigurationOptions } from "../AzureAppConfigurationOptions.js";
67
import { FeatureFlagTracingOptions } from "./FeatureFlagTracingOptions.js";
78
import { AIConfigurationTracingOptions } from "./AIConfigurationTracingOptions.js";
@@ -52,15 +53,7 @@ export function listConfigurationSettingsWithTrace(
5253
client: AppConfigurationClient,
5354
listOptions: ListConfigurationSettingsOptions
5455
) {
55-
const actualListOptions = { ...listOptions };
56-
if (requestTracingOptions.enabled) {
57-
actualListOptions.requestOptions = {
58-
customHeaders: {
59-
[CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions)
60-
}
61-
};
62-
}
63-
56+
const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions);
6457
return client.listConfigurationSettings(actualListOptions);
6558
}
6659

@@ -70,20 +63,43 @@ export function getConfigurationSettingWithTrace(
7063
configurationSettingId: ConfigurationSettingId,
7164
getOptions?: GetConfigurationSettingOptions,
7265
) {
73-
const actualGetOptions = { ...getOptions };
66+
const actualGetOptions = applyRequestTracing(requestTracingOptions, getOptions);
67+
return client.getConfigurationSetting(configurationSettingId, actualGetOptions);
68+
}
69+
70+
export function getSnapshotWithTrace(
71+
requestTracingOptions: RequestTracingOptions,
72+
client: AppConfigurationClient,
73+
snapshotName: string,
74+
getOptions?: GetSnapshotOptions
75+
) {
76+
const actualGetOptions = applyRequestTracing(requestTracingOptions, getOptions);
77+
return client.getSnapshot(snapshotName, actualGetOptions);
78+
}
7479

80+
export function listConfigurationSettingsForSnapshotWithTrace(
81+
requestTracingOptions: RequestTracingOptions,
82+
client: AppConfigurationClient,
83+
snapshotName: string,
84+
listOptions?: ListConfigurationSettingsForSnapshotOptions
85+
) {
86+
const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions);
87+
return client.listConfigurationSettingsForSnapshot(snapshotName, actualListOptions);
88+
}
89+
90+
function applyRequestTracing<T extends OperationOptions>(requestTracingOptions: RequestTracingOptions, operationOptions?: T) {
91+
const actualOptions = { ...operationOptions };
7592
if (requestTracingOptions.enabled) {
76-
actualGetOptions.requestOptions = {
93+
actualOptions.requestOptions = {
7794
customHeaders: {
7895
[CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions)
7996
}
8097
};
8198
}
82-
83-
return client.getConfigurationSetting(configurationSettingId, actualGetOptions);
99+
return actualOptions;
84100
}
85101

86-
export function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string {
102+
function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string {
87103
/*
88104
RequestType: 'Startup' during application starting up, 'Watch' after startup completed.
89105
Host: identify with defined envs
@@ -227,4 +243,3 @@ export function isWebWorker() {
227243

228244
return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected;
229245
}
230-

src/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type SettingSelector = {
1717
* For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\).
1818
* e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`.
1919
*/
20-
keyFilter: string,
20+
keyFilter?: string,
2121

2222
/**
2323
* The label filter to apply when querying Azure App Configuration for key-values.
@@ -29,6 +29,15 @@ export type SettingSelector = {
2929
* @defaultValue `LabelFilter.Null`, matching key-values without a label.
3030
*/
3131
labelFilter?: string
32+
33+
/**
34+
* The name of snapshot to load from App Configuration.
35+
*
36+
* @remarks
37+
* 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.
38+
* If snapshot name is used in a selector, no key and label filter should be used for it. Otherwise, an exception will be thrown.
39+
*/
40+
snapshotName?: string
3241
};
3342

3443
/**

test/featureFlag.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as chai from "chai";
55
import * as chaiAsPromised from "chai-as-promised";
66
import { featureFlagContentType } from "@azure/app-configuration";
77
import { load } from "./exportedApi.js";
8-
import { MAX_TIME_OUT, createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js";
8+
import { MAX_TIME_OUT, mockAppConfigurationClientGetSnapshot, mockAppConfigurationClientListConfigurationSettingsForSnapshot, createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js";
99
chai.use(chaiAsPromised);
1010
const expect = chai.expect;
1111

@@ -337,4 +337,25 @@ describe("feature flags", function () {
337337
expect(featureFlag.telemetry.metadata.ETag).equals("ETag");
338338
expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`);
339339
});
340+
341+
it("should load feature flags from snapshot", async () => {
342+
const snapshotName = "Test";
343+
mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"});
344+
mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[createMockedFeatureFlag("TestFeature", { enabled: true })]]);
345+
const connectionString = createMockedConnectionString();
346+
const settings = await load(connectionString, {
347+
featureFlagOptions: {
348+
enabled: true,
349+
selectors: [ { snapshotName: snapshotName } ]
350+
}
351+
});
352+
expect(settings).not.undefined;
353+
expect(settings.get("feature_management")).not.undefined;
354+
const featureFlags = settings.get<any>("feature_management").feature_flags;
355+
expect((featureFlags as []).length).equals(1);
356+
const featureFlag = featureFlags[0];
357+
expect(featureFlag.id).equals("TestFeature");
358+
expect(featureFlag.enabled).equals(true);
359+
restoreMocks();
360+
});
340361
});

0 commit comments

Comments
 (0)