Skip to content

Support tag filter #188

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 37 commits into from
Aug 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 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
53544af
wip
zhiyuanliang-ms Apr 23, 2025
64f7604
wip
zhiyuanliang-ms Apr 23, 2025
5fc57b0
support tag filter
zhiyuanliang-ms Apr 23, 2025
1f5d9d8
add test
zhiyuanliang-ms Apr 23, 2025
994c10e
update test
zhiyuanliang-ms Apr 23, 2025
5b89cb6
Merge branch 'zhiyuanliang/select-snapshot' of https://github.com/Azu…
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
00421f9
Merge branch 'zhiyuanliang/select-snapshot' of https://github.com/Azu…
zhiyuanliang-ms Apr 29, 2025
667f721
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms May 13, 2025
11b5325
Merge branch 'zhiyuanliang/select-snapshot' of https://github.com/Azu…
zhiyuanliang-ms May 13, 2025
ca3c113
update
zhiyuanliang-ms May 19, 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
c0d611c
Merge branch 'zhiyuanliang/select-snapshot' of https://github.com/Azu…
zhiyuanliang-ms May 19, 2025
b985509
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms May 22, 2025
7a246d9
fix lint
zhiyuanliang-ms May 22, 2025
58ff6c3
update testcase
zhiyuanliang-ms May 22, 2025
1809619
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms Aug 1, 2025
fe98b43
update
zhiyuanliang-ms Aug 1, 2025
b373d1e
add more testcases
zhiyuanliang-ms Aug 4, 2025
4bfc623
Merge branch 'main' of https://github.com/Azure/AppConfiguration-Java…
zhiyuanliang-ms Aug 4, 2025
505c542
correct null tag test
zhiyuanliang-ms Aug 5, 2025
1433789
Merge branch 'main' into zhiyuanliang/tag-filter
zhiyuanliang-ms Aug 7, 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
34 changes: 29 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"uuid": "^9.0.1"
},
"dependencies": {
"@azure/app-configuration": "^1.6.1",
"@azure/app-configuration": "^1.8.0",
"@azure/identity": "^4.2.1",
"@azure/keyvault-secrets": "^4.7.0",
"jsonc-parser": "^3.3.1"
Expand Down
43 changes: 39 additions & 4 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
if (selector.snapshotName === undefined) {
const listOptions: ListConfigurationSettingsOptions = {
keyFilter: selector.keyFilter,
labelFilter: selector.labelFilter
labelFilter: selector.labelFilter,
tagsFilter: selector.tagFilters
};
const pageEtags: string[] = [];
const pageIterator = listConfigurationSettingsWithTrace(
Expand Down Expand Up @@ -727,6 +728,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
const listOptions: ListConfigurationSettingsOptions = {
keyFilter: selector.keyFilter,
labelFilter: selector.labelFilter,
tagsFilter: selector.tagFilters,
pageEtags: selector.pageEtags
};

Expand Down Expand Up @@ -966,7 +968,11 @@ 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 && s.snapshotName === selector.snapshotName);
const existingSelectorIndex = uniqueSelectors.findIndex(
s => s.keyFilter === selector.keyFilter &&
s.labelFilter === selector.labelFilter &&
s.snapshotName === selector.snapshotName &&
areTagFiltersEqual(s.tagFilters, selector.tagFilters));
if (existingSelectorIndex >= 0) {
uniqueSelectors.splice(existingSelectorIndex, 1);
}
Expand All @@ -976,8 +982,8 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector
return uniqueSelectors.map(selectorCandidate => {
const selector = { ...selectorCandidate };
if (selector.snapshotName) {
if (selector.keyFilter || selector.labelFilter) {
throw new ArgumentError("Key or label filter should not be used for a snapshot.");
if (selector.keyFilter || selector.labelFilter || selector.tagFilters) {
throw new ArgumentError("Key, label or tag filters should not be specified while selecting a snapshot.");
}
} else {
if (!selector.keyFilter) {
Expand All @@ -989,11 +995,31 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
}
if (selector.tagFilters) {
validateTagFilters(selector.tagFilters);
}
}
return selector;
});
}

function areTagFiltersEqual(tagsA?: string[], tagsB?: string[]): boolean {
if (!tagsA && !tagsB) {
return true;
}
if (!tagsA || !tagsB) {
return false;
}
if (tagsA.length !== tagsB.length) {
return false;
}

const sortedStringA = [...tagsA].sort().join("\n");
const sortedStringB = [...tagsB].sort().join("\n");

return sortedStringA === sortedStringB;
}

function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] {
if (selectors === undefined || selectors.length === 0) {
// Default selector: key: *, label: \0
Expand All @@ -1014,3 +1040,12 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
});
return getValidSettingSelectors(selectors);
}

function validateTagFilters(tagFilters: string[]): void {
for (const tagFilter of tagFilters) {
const res = tagFilter.split("=");
if (res[0] === "" || res.length !== 2) {
throw new Error(`Invalid tag filter: ${tagFilter}. Tag filter must follow the format "tagName=tagValue".`);
}
}
}
20 changes: 20 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export type SettingSelector = {
*/
labelFilter?: string

/**
* The tag filter to apply when querying Azure App Configuration for key-values.
*
* @remarks
* Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here.
* 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.
* Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags.
*/
tagFilters?: string[]

/**
* The name of snapshot to load from App Configuration.
*
Expand Down Expand Up @@ -59,3 +69,13 @@ export enum LabelFilter {
*/
Null = "\0"
}

/**
* TagFilter is used to filter key-values based on tags.
*/
export enum TagFilter {
/**
* Represents empty tag value.
*/
Null = ""
}
75 changes: 75 additions & 0 deletions test/featureFlag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ const mockedKVs = [{
createMockedFeatureFlag("FlagWithTestLabel", { enabled: true }, {label: "Test"}),
createMockedFeatureFlag("Alpha_1", { enabled: true }),
createMockedFeatureFlag("Alpha_2", { enabled: false }),
createMockedFeatureFlag("DevFeatureFlag", { enabled: true }, { tags: { "environment": "dev" } }),
createMockedFeatureFlag("ProdFeatureFlag", { enabled: false }, { tags: { "environment": "prod" } }),
createMockedFeatureFlag("TaggedFeature", { enabled: true }, { tags: { "team": "backend", "priority": "high" } }),
createMockedFeatureFlag("Telemetry_1", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag"}),
createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag", label: "Test"}),
createMockedFeatureFlag("NoPercentileAndSeed", {
Expand Down Expand Up @@ -338,6 +341,78 @@ describe("feature flags", function () {
expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`);
});

it("should load feature flags using tag filters", async () => {
const connectionString = createMockedConnectionString();

// Test filtering by environment=dev tag
const settingsWithDevTag = await load(connectionString, {
featureFlagOptions: {
enabled: true,
selectors: [{
keyFilter: "*",
tagFilters: ["environment=dev"]
}]
}
});

expect(settingsWithDevTag).not.undefined;
expect(settingsWithDevTag.get("feature_management")).not.undefined;
let featureFlags = settingsWithDevTag.get<any>("feature_management").feature_flags;
expect(featureFlags).not.undefined;
expect((featureFlags as []).length).equals(1);
expect(featureFlags[0].id).equals("DevFeatureFlag");
expect(featureFlags[0].enabled).equals(true);

// Test filtering by environment=prod tag
const settingsWithProdTag = await load(connectionString, {
featureFlagOptions: {
enabled: true,
selectors: [{
keyFilter: "*",
tagFilters: ["environment=prod"]
}]
}
});

featureFlags = settingsWithProdTag.get<any>("feature_management").feature_flags;
expect(featureFlags).not.undefined;
expect((featureFlags as []).length).equals(1);
expect(featureFlags[0].id).equals("ProdFeatureFlag");
expect(featureFlags[0].enabled).equals(false);

// Test filtering by multiple tags (team=backend AND priority=high)
const settingsWithMultipleTags = await load(connectionString, {
featureFlagOptions: {
enabled: true,
selectors: [{
keyFilter: "*",
tagFilters: ["team=backend", "priority=high"]
}]
}
});

featureFlags = settingsWithMultipleTags.get<any>("feature_management").feature_flags;
expect(featureFlags).not.undefined;
expect((featureFlags as []).length).equals(1);
expect(featureFlags[0].id).equals("TaggedFeature");
expect(featureFlags[0].enabled).equals(true);

// Test filtering by non-existent tag
const settingsWithNonExistentTag = await load(connectionString, {
featureFlagOptions: {
enabled: true,
selectors: [{
keyFilter: "*",
tagFilters: ["nonexistent=tag"]
}]
}
});

featureFlags = settingsWithNonExistentTag.get<any>("feature_management").feature_flags;
expect(featureFlags).not.undefined;
expect((featureFlags as []).length).equals(0);
});

it("should load feature flags from snapshot", async () => {
const snapshotName = "Test";
mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"});
Expand Down
Loading