Skip to content

Commit 027faa2

Browse files
Merge branch 'main' into zhiyuanliang/secret-refresh
2 parents a865bfd + 867223e commit 027faa2

File tree

9 files changed

+244
-59
lines changed

9 files changed

+244
-59
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azure/app-configuration-provider",
3-
"version": "2.0.2",
3+
"version": "2.1.0",
44
"description": "The JavaScript configuration provider for Azure App Configuration",
55
"main": "dist/index.js",
66
"module": "./dist-esm/index.js",

src/AzureAppConfigurationImpl.ts

Lines changed: 110 additions & 36 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";
@@ -30,7 +43,14 @@ import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } fro
3043
import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js";
3144
import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js";
3245
import { RefreshTimer } from "./refresh/RefreshTimer.js";
33-
import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js";
46+
import {
47+
RequestTracingOptions,
48+
getConfigurationSettingWithTrace,
49+
listConfigurationSettingsWithTrace,
50+
getSnapshotWithTrace,
51+
listConfigurationSettingsForSnapshotWithTrace,
52+
requestTracingEnabled
53+
} from "./requestTracing/utils.js";
3454
import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js";
3555
import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js";
3656
import { KeyFilter, LabelFilter, SettingSelector } from "./types.js";
@@ -41,9 +61,6 @@ import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError
4161
const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds
4262

4363
type PagedSettingSelector = SettingSelector & {
44-
/**
45-
* Key: page eTag, Value: feature flag configurations
46-
*/
4764
pageEtags?: string[];
4865
};
4966

@@ -468,26 +485,49 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
468485
);
469486

470487
for (const selector of selectorsToUpdate) {
471-
const listOptions: ListConfigurationSettingsOptions = {
472-
keyFilter: selector.keyFilter,
473-
labelFilter: selector.labelFilter
474-
};
475-
476-
const pageEtags: string[] = [];
477-
const pageIterator = listConfigurationSettingsWithTrace(
478-
this.#requestTraceOptions,
479-
client,
480-
listOptions
481-
).byPage();
482-
for await (const page of pageIterator) {
483-
pageEtags.push(page.etag ?? "");
484-
for (const setting of page.items) {
485-
if (loadFeatureFlag === isFeatureFlag(setting)) {
486-
loadedSettings.push(setting);
488+
if (selector.snapshotName === undefined) {
489+
const listOptions: ListConfigurationSettingsOptions = {
490+
keyFilter: selector.keyFilter,
491+
labelFilter: selector.labelFilter
492+
};
493+
const pageEtags: string[] = [];
494+
const pageIterator = listConfigurationSettingsWithTrace(
495+
this.#requestTraceOptions,
496+
client,
497+
listOptions
498+
).byPage();
499+
500+
for await (const page of pageIterator) {
501+
pageEtags.push(page.etag ?? "");
502+
for (const setting of page.items) {
503+
if (loadFeatureFlag === isFeatureFlag(setting)) {
504+
loadedSettings.push(setting);
505+
}
506+
}
507+
}
508+
selector.pageEtags = pageEtags;
509+
} else { // snapshot selector
510+
const snapshot = await this.#getSnapshot(selector.snapshotName);
511+
if (snapshot === undefined) {
512+
throw new InvalidOperationError(`Could not find snapshot with name ${selector.snapshotName}.`);
513+
}
514+
if (snapshot.compositionType != KnownSnapshotComposition.Key) {
515+
throw new InvalidOperationError(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`);
516+
}
517+
const pageIterator = listConfigurationSettingsForSnapshotWithTrace(
518+
this.#requestTraceOptions,
519+
client,
520+
selector.snapshotName
521+
).byPage();
522+
523+
for await (const page of pageIterator) {
524+
for (const setting of page.items) {
525+
if (loadFeatureFlag === isFeatureFlag(setting)) {
526+
loadedSettings.push(setting);
527+
}
487528
}
488529
}
489530
}
490-
selector.pageEtags = pageEtags;
491531
}
492532

493533
if (loadFeatureFlag) {
@@ -678,6 +718,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
678718
async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise<boolean> {
679719
const funcToExecute = async (client) => {
680720
for (const selector of selectors) {
721+
if (selector.snapshotName) { // skip snapshot selector
722+
continue;
723+
}
681724
const listOptions: ListConfigurationSettingsOptions = {
682725
keyFilter: selector.keyFilter,
683726
labelFilter: selector.labelFilter,
@@ -729,6 +772,29 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
729772
return response;
730773
}
731774

775+
async #getSnapshot(snapshotName: string, customOptions?: GetSnapshotOptions): Promise<GetSnapshotResponse | undefined> {
776+
const funcToExecute = async (client) => {
777+
return getSnapshotWithTrace(
778+
this.#requestTraceOptions,
779+
client,
780+
snapshotName,
781+
customOptions
782+
);
783+
};
784+
785+
let response: GetSnapshotResponse | undefined;
786+
try {
787+
response = await this.#executeWithFailoverPolicy(funcToExecute);
788+
} catch (error) {
789+
if (isRestError(error) && error.statusCode === 404) {
790+
response = undefined;
791+
} else {
792+
throw error;
793+
}
794+
}
795+
return response;
796+
}
797+
732798
// Only operations related to Azure App Configuration should be executed with failover policy.
733799
async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise<any>): Promise<any> {
734800
let clientWrappers = await this.#clientManager.getClients();
@@ -893,11 +959,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
893959
}
894960
}
895961

896-
function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {
897-
// below code deduplicates selectors by keyFilter and labelFilter, the latter selector wins
962+
function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] {
963+
// below code deduplicates selectors, the latter selector wins
898964
const uniqueSelectors: SettingSelector[] = [];
899965
for (const selector of selectors) {
900-
const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter);
966+
const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter && s.snapshotName === selector.snapshotName);
901967
if (existingSelectorIndex >= 0) {
902968
uniqueSelectors.splice(existingSelectorIndex, 1);
903969
}
@@ -906,14 +972,20 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {
906972

907973
return uniqueSelectors.map(selectorCandidate => {
908974
const selector = { ...selectorCandidate };
909-
if (!selector.keyFilter) {
910-
throw new ArgumentError("Key filter cannot be null or empty.");
911-
}
912-
if (!selector.labelFilter) {
913-
selector.labelFilter = LabelFilter.Null;
914-
}
915-
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
916-
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
975+
if (selector.snapshotName) {
976+
if (selector.keyFilter || selector.labelFilter) {
977+
throw new ArgumentError("Key or label filter should not be used for a snapshot.");
978+
}
979+
} else {
980+
if (!selector.keyFilter) {
981+
throw new ArgumentError("Key filter cannot be null or empty.");
982+
}
983+
if (!selector.labelFilter) {
984+
selector.labelFilter = LabelFilter.Null;
985+
}
986+
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
987+
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
988+
}
917989
}
918990
return selector;
919991
});
@@ -924,7 +996,7 @@ function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelect
924996
// Default selector: key: *, label: \0
925997
return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }];
926998
}
927-
return getValidSelectors(selectors);
999+
return getValidSettingSelectors(selectors);
9281000
}
9291001

9301002
function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] {
@@ -933,7 +1005,9 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
9331005
return [{ keyFilter: `${featureFlagPrefix}${KeyFilter.Any}`, labelFilter: LabelFilter.Null }];
9341006
}
9351007
selectors.forEach(selector => {
936-
selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`;
1008+
if (selector.keyFilter) {
1009+
selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`;
1010+
}
9371011
});
938-
return getValidSelectors(selectors);
1012+
return getValidSettingSelectors(selectors);
9391013
}

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";
@@ -53,15 +54,7 @@ export function listConfigurationSettingsWithTrace(
5354
client: AppConfigurationClient,
5455
listOptions: ListConfigurationSettingsOptions
5556
) {
56-
const actualListOptions = { ...listOptions };
57-
if (requestTracingOptions.enabled) {
58-
actualListOptions.requestOptions = {
59-
customHeaders: {
60-
[CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions)
61-
}
62-
};
63-
}
64-
57+
const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions);
6558
return client.listConfigurationSettings(actualListOptions);
6659
}
6760

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

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

87-
export function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string {
103+
function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string {
88104
/*
89105
RequestType: 'Startup' during application starting up, 'Watch' after startup completed.
90106
Host: identify with defined envs
@@ -231,4 +247,3 @@ export function isWebWorker() {
231247

232248
return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected;
233249
}
234-

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
/**

src/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
export const VERSION = "2.0.2";
4+
export const VERSION = "2.1.0";

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)