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

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
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
32 changes: 27 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"
}
Expand Down
52 changes: 46 additions & 6 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError

const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds

const MAX_TAG_FILTER_COUNT = 5;

type PagedSettingSelector = SettingSelector & {
/**
* Key: page eTag, Value: feature flag configurations
Expand Down Expand Up @@ -476,7 +478,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 @@ -693,6 +696,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 @@ -911,7 +915,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 @@ -921,24 +929,44 @@ 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 filter should not be used for a snapshot.");
}
} else {
if (!selector.keyFilter) {
throw new ArgumentError("Key filter cannot be null or empty.");
if (!selector.keyFilter && (!selector.tagFilters || selector.tagFilters.length === 0)) {
throw new ArgumentError("Key filter and tag filter cannot both 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.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;
Comment on lines +964 to +967

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check robust? \n is not allowed in tag?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I followed .NET provider's implementation: https://github.com/Azure/AppConfiguration-DotnetProvider/pull/637/files#diff-5f9d810c153f73ab4382bfddf40b76b7c30bba39beae6e74557bc078115f3f95

I checked out backend code, we don't forbid \n but the portal will not allow you to enter \n when creating a tag.

}

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

function validateTagFilters(tagFilters: string[]): void {
if (tagFilters.length > MAX_TAG_FILTER_COUNT) {
throw new Error(`The number of tag filters cannot exceed ${MAX_TAG_FILTER_COUNT}.`);
}
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 = ""
}
58 changes: 54 additions & 4 deletions test/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ const mockedKVs = [{
}, {
key: "TestKey",
label: "Test",
tags: {"testTag": ""},
value: "TestValue",
}, {
key: "TestKey",
label: "Prod",
tags: {"testTag": ""},
value: "TestValueForProd",
}, {
key: "KeyForNullValue",
Expand Down Expand Up @@ -73,6 +75,18 @@ const mockedKVs = [{
}
}),
contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8"
}, {
key: "keyWithMultipleTags",
value: "someValue",
tags: {"tag1": "someValue", "tag2": "someValue"}
}, {
key: "keyWithTag1",
value: "someValue",
tags: {"tag1": "someValue"}
}, {
key: "keyWithTag2",
value: "someValue",
tags: {"tag2": "someValue"}
}
].map(createMockedKeyValue);

Expand Down Expand Up @@ -128,7 +142,7 @@ describe("load", function () {
selectors: [{
labelFilter: "\0"
}]
})).eventually.rejectedWith("Key filter cannot be null or empty.");
})).eventually.rejectedWith("Key filter and tag filter cannot both be null or empty.");
});

it("should throw error given invalid snapshot selector", async () => {
Expand All @@ -138,7 +152,7 @@ describe("load", function () {
snapshotName: "Test",
labelFilter: "\0"
}]
})).eventually.rejectedWith("Key or label filter should not be used for a snapshot.");
})).eventually.rejectedWith("Key, label or tag filter should not be used for a snapshot.");
});

it("should not include feature flags directly in the settings", async () => {
Expand All @@ -165,6 +179,30 @@ describe("load", function () {
expect(settings.get("app.settings.fontFamily")).undefined;
});

it("should filter by tags, has(key) and get(key) should work", async () => {
const connectionString = createMockedConnectionString();
const loadWithTag1 = await load(connectionString, {
selectors: [{
tagFilters: ["tag1=someValue"]
}]
});
expect(loadWithTag1.has("keyWithTag1")).true;
expect(loadWithTag1.get("keyWithTag1")).eq("someValue");
expect(loadWithTag1.has("keyWithTag2")).false;
expect(loadWithTag1.has("keyWithMultipleTags")).true;
expect(loadWithTag1.get("keyWithMultipleTags")).eq("someValue");

const loadWithMultipleTags = await load(connectionString, {
selectors: [{
tagFilters: ["tag1=someValue", "tag2=someValue"]
}]
});
expect(loadWithMultipleTags.has("keyWithTag1")).false;
expect(loadWithMultipleTags.has("keyWithTag2")).false;
expect(loadWithMultipleTags.has("keyWithMultipleTags")).true;
expect(loadWithMultipleTags.get("keyWithMultipleTags")).eq("someValue");
});

it("should also work with other ReadonlyMap APIs", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
Expand Down Expand Up @@ -274,6 +312,16 @@ describe("load", function () {
return expect(loadWithMultipleLabelFilter).to.eventually.rejectedWith("The characters '*' and ',' are not supported in label filters.");
});

it("should throw exception when there is any invalid tag filter", async () => {
const connectionString = createMockedConnectionString();
const loadWithInvalidTagFilter = load(connectionString, {
selectors: [{
tagFilters: ["testTag"]
}]
});
return expect(loadWithInvalidTagFilter).to.eventually.rejectedWith("Tag filter must follow the format \"tagName=tagValue\"");
});

it("should override config settings with same key but different label", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
Expand All @@ -294,13 +342,15 @@ describe("load", function () {
const settings = await load(connectionString, {
selectors: [{
keyFilter: "Test*",
labelFilter: "Prod"
labelFilter: "Prod",
tagFilters: ["testTag="]
}, {
keyFilter: "Test*",
labelFilter: "Test"
}, {
keyFilter: "Test*",
labelFilter: "Prod"
labelFilter: "Prod",
tagFilters: ["testTag="]
}]
});
expect(settings).not.undefined;
Expand Down
13 changes: 10 additions & 3 deletions test/utils/testHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function _sha256(input) {
function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) {
const keyFilter = listOptions?.keyFilter ?? "*";
const labelFilter = listOptions?.labelFilter ?? "*";
const tagsFilter = listOptions?.tagsFilter ?? [];
return unfilteredKvs.filter(kv => {
const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter;
let labelMatched = false;
Expand All @@ -38,7 +39,14 @@ function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) {
} else {
labelMatched = kv.label === labelFilter;
}
return keyMatched && labelMatched;
let tagsMatched = true;
if (tagsFilter.length > 0) {
tagsMatched = tagsFilter.every(tag => {
const [tagName, tagValue] = tag.split("=");
return kv.tags && kv.tags[tagName] === tagValue;
});
}
return keyMatched && labelMatched && tagsMatched;
});
}

Expand Down Expand Up @@ -233,8 +241,7 @@ const createMockedKeyVaultReference = (key: string, vaultUri: string): Configura
key,
contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8",
lastModified: new Date(),
tags: {
},
tags: {},
etag: uuid.v4(),
isReadOnly: false,
});
Expand Down