Skip to content

Commit e36e067

Browse files
Support tag filter (#188)
* support snapshot * add testcase * add testcase * fix lint * update * wip * wip * support tag filter * add test * update test * update testcase * update * add more testcases * update * fix lint * update testcase * update * add more testcases * correct null tag test
1 parent c6acf18 commit e36e067

File tree

8 files changed

+401
-17
lines changed

8 files changed

+401
-17
lines changed

package-lock.json

Lines changed: 29 additions & 5 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
@@ -55,7 +55,7 @@
5555
"uuid": "^9.0.1"
5656
},
5757
"dependencies": {
58-
"@azure/app-configuration": "^1.6.1",
58+
"@azure/app-configuration": "^1.8.0",
5959
"@azure/identity": "^4.2.1",
6060
"@azure/keyvault-secrets": "^4.7.0",
6161
"jsonc-parser": "^3.3.1"

src/AzureAppConfigurationImpl.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
491491
if (selector.snapshotName === undefined) {
492492
const listOptions: ListConfigurationSettingsOptions = {
493493
keyFilter: selector.keyFilter,
494-
labelFilter: selector.labelFilter
494+
labelFilter: selector.labelFilter,
495+
tagsFilter: selector.tagFilters
495496
};
496497
const pageEtags: string[] = [];
497498
const pageIterator = listConfigurationSettingsWithTrace(
@@ -727,6 +728,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
727728
const listOptions: ListConfigurationSettingsOptions = {
728729
keyFilter: selector.keyFilter,
729730
labelFilter: selector.labelFilter,
731+
tagsFilter: selector.tagFilters,
730732
pageEtags: selector.pageEtags
731733
};
732734

@@ -966,7 +968,11 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector
966968
// below code deduplicates selectors, the latter selector wins
967969
const uniqueSelectors: SettingSelector[] = [];
968970
for (const selector of selectors) {
969-
const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter && s.snapshotName === selector.snapshotName);
971+
const existingSelectorIndex = uniqueSelectors.findIndex(
972+
s => s.keyFilter === selector.keyFilter &&
973+
s.labelFilter === selector.labelFilter &&
974+
s.snapshotName === selector.snapshotName &&
975+
areTagFiltersEqual(s.tagFilters, selector.tagFilters));
970976
if (existingSelectorIndex >= 0) {
971977
uniqueSelectors.splice(existingSelectorIndex, 1);
972978
}
@@ -976,8 +982,8 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector
976982
return uniqueSelectors.map(selectorCandidate => {
977983
const selector = { ...selectorCandidate };
978984
if (selector.snapshotName) {
979-
if (selector.keyFilter || selector.labelFilter) {
980-
throw new ArgumentError("Key or label filter should not be used for a snapshot.");
985+
if (selector.keyFilter || selector.labelFilter || selector.tagFilters) {
986+
throw new ArgumentError("Key, label or tag filters should not be specified while selecting a snapshot.");
981987
}
982988
} else {
983989
if (!selector.keyFilter) {
@@ -989,11 +995,31 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector
989995
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
990996
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
991997
}
998+
if (selector.tagFilters) {
999+
validateTagFilters(selector.tagFilters);
1000+
}
9921001
}
9931002
return selector;
9941003
});
9951004
}
9961005

1006+
function areTagFiltersEqual(tagsA?: string[], tagsB?: string[]): boolean {
1007+
if (!tagsA && !tagsB) {
1008+
return true;
1009+
}
1010+
if (!tagsA || !tagsB) {
1011+
return false;
1012+
}
1013+
if (tagsA.length !== tagsB.length) {
1014+
return false;
1015+
}
1016+
1017+
const sortedStringA = [...tagsA].sort().join("\n");
1018+
const sortedStringB = [...tagsB].sort().join("\n");
1019+
1020+
return sortedStringA === sortedStringB;
1021+
}
1022+
9971023
function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] {
9981024
if (selectors === undefined || selectors.length === 0) {
9991025
// Default selector: key: *, label: \0
@@ -1014,3 +1040,12 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
10141040
});
10151041
return getValidSettingSelectors(selectors);
10161042
}
1043+
1044+
function validateTagFilters(tagFilters: string[]): void {
1045+
for (const tagFilter of tagFilters) {
1046+
const res = tagFilter.split("=");
1047+
if (res[0] === "" || res.length !== 2) {
1048+
throw new Error(`Invalid tag filter: ${tagFilter}. Tag filter must follow the format "tagName=tagValue".`);
1049+
}
1050+
}
1051+
}

src/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ export type SettingSelector = {
3030
*/
3131
labelFilter?: string
3232

33+
/**
34+
* The tag filter to apply when querying Azure App Configuration for key-values.
35+
*
36+
* @remarks
37+
* Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here.
38+
* Built in tag filter value is `TagFilter.Null`, which indicates the tag has no value. For example, `tagName=${TagFilter.Null}` will match all key-values with the tag "tagName" that has no value.
39+
* Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags.
40+
*/
41+
tagFilters?: string[]
42+
3343
/**
3444
* The name of snapshot to load from App Configuration.
3545
*
@@ -59,3 +69,13 @@ export enum LabelFilter {
5969
*/
6070
Null = "\0"
6171
}
72+
73+
/**
74+
* TagFilter is used to filter key-values based on tags.
75+
*/
76+
export enum TagFilter {
77+
/**
78+
* Represents empty tag value.
79+
*/
80+
Null = ""
81+
}

test/featureFlag.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ const mockedKVs = [{
5555
createMockedFeatureFlag("FlagWithTestLabel", { enabled: true }, {label: "Test"}),
5656
createMockedFeatureFlag("Alpha_1", { enabled: true }),
5757
createMockedFeatureFlag("Alpha_2", { enabled: false }),
58+
createMockedFeatureFlag("DevFeatureFlag", { enabled: true }, { tags: { "environment": "dev" } }),
59+
createMockedFeatureFlag("ProdFeatureFlag", { enabled: false }, { tags: { "environment": "prod" } }),
60+
createMockedFeatureFlag("TaggedFeature", { enabled: true }, { tags: { "team": "backend", "priority": "high" } }),
5861
createMockedFeatureFlag("Telemetry_1", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag"}),
5962
createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag", label: "Test"}),
6063
createMockedFeatureFlag("NoPercentileAndSeed", {
@@ -338,6 +341,78 @@ describe("feature flags", function () {
338341
expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`);
339342
});
340343

344+
it("should load feature flags using tag filters", async () => {
345+
const connectionString = createMockedConnectionString();
346+
347+
// Test filtering by environment=dev tag
348+
const settingsWithDevTag = await load(connectionString, {
349+
featureFlagOptions: {
350+
enabled: true,
351+
selectors: [{
352+
keyFilter: "*",
353+
tagFilters: ["environment=dev"]
354+
}]
355+
}
356+
});
357+
358+
expect(settingsWithDevTag).not.undefined;
359+
expect(settingsWithDevTag.get("feature_management")).not.undefined;
360+
let featureFlags = settingsWithDevTag.get<any>("feature_management").feature_flags;
361+
expect(featureFlags).not.undefined;
362+
expect((featureFlags as []).length).equals(1);
363+
expect(featureFlags[0].id).equals("DevFeatureFlag");
364+
expect(featureFlags[0].enabled).equals(true);
365+
366+
// Test filtering by environment=prod tag
367+
const settingsWithProdTag = await load(connectionString, {
368+
featureFlagOptions: {
369+
enabled: true,
370+
selectors: [{
371+
keyFilter: "*",
372+
tagFilters: ["environment=prod"]
373+
}]
374+
}
375+
});
376+
377+
featureFlags = settingsWithProdTag.get<any>("feature_management").feature_flags;
378+
expect(featureFlags).not.undefined;
379+
expect((featureFlags as []).length).equals(1);
380+
expect(featureFlags[0].id).equals("ProdFeatureFlag");
381+
expect(featureFlags[0].enabled).equals(false);
382+
383+
// Test filtering by multiple tags (team=backend AND priority=high)
384+
const settingsWithMultipleTags = await load(connectionString, {
385+
featureFlagOptions: {
386+
enabled: true,
387+
selectors: [{
388+
keyFilter: "*",
389+
tagFilters: ["team=backend", "priority=high"]
390+
}]
391+
}
392+
});
393+
394+
featureFlags = settingsWithMultipleTags.get<any>("feature_management").feature_flags;
395+
expect(featureFlags).not.undefined;
396+
expect((featureFlags as []).length).equals(1);
397+
expect(featureFlags[0].id).equals("TaggedFeature");
398+
expect(featureFlags[0].enabled).equals(true);
399+
400+
// Test filtering by non-existent tag
401+
const settingsWithNonExistentTag = await load(connectionString, {
402+
featureFlagOptions: {
403+
enabled: true,
404+
selectors: [{
405+
keyFilter: "*",
406+
tagFilters: ["nonexistent=tag"]
407+
}]
408+
}
409+
});
410+
411+
featureFlags = settingsWithNonExistentTag.get<any>("feature_management").feature_flags;
412+
expect(featureFlags).not.undefined;
413+
expect((featureFlags as []).length).equals(0);
414+
});
415+
341416
it("should load feature flags from snapshot", async () => {
342417
const snapshotName = "Test";
343418
mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"});

0 commit comments

Comments
 (0)