Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"dev": "rollup --config --watch",
"lint": "eslint src/ test/",
"fix-lint": "eslint src/ test/ --fix",
"test": "mocha out/test/load.test.{js,cjs,mjs} --parallel"
"test": "mocha out/test/*.test.{js,cjs,mjs} --parallel"
},
"repository": {
"type": "git",
Expand Down
3 changes: 3 additions & 0 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
if (isInputError(error)) {
throw error;
}
if (isRestError(error) && !isFailoverableError(error)) {
throw error;
}
if (abortSignal.aborted) {
return;
}
Expand Down
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
95 changes: 91 additions & 4 deletions test/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,24 @@ const mockedKVs = [{
tags: {"tag1": "someValue", "tag2": "someValue"}
}, {
key: "keyWithTag1",
value: "someValue",
value: "someValue1",
tags: {"tag1": "someValue"}
}, {
key: "keyWithTag2",
value: "someValue",
value: "someValue2",
tags: {"tag2": "someValue"}
}, {
key: "keyWithNullTag",
value: "valueWithNullTag",
tags: {"nullTag": "\0"}
}, {
key: "keyWithEscapedComma",
value: "valueWithEscapedComma",
tags: {"tag": "value\\,with\\,commas"}
}, {
key: "keyWithEmptyTag",
value: "valueWithEmptyTag",
tags: {"emptyTag": ""}
}
].map(createMockedKeyValue);

Expand Down Expand Up @@ -188,7 +200,7 @@ describe("load", function () {
}]
});
expect(loadWithTag1.has("keyWithTag1")).true;
expect(loadWithTag1.get("keyWithTag1")).eq("someValue");
expect(loadWithTag1.get("keyWithTag1")).eq("someValue1");
expect(loadWithTag1.has("keyWithTag2")).false;
expect(loadWithTag1.has("keyWithMultipleTags")).true;
expect(loadWithTag1.get("keyWithMultipleTags")).eq("someValue");
Expand All @@ -205,6 +217,53 @@ describe("load", function () {
expect(loadWithMultipleTags.get("keyWithMultipleTags")).eq("someValue");
});

it("should filter by nullTag to load key values with null tag", async () => {
const connectionString = createMockedConnectionString();
const loadWithNullTag = await load(connectionString, {
selectors: [{
keyFilter: "*",
tagFilters: ["nullTag=\0"]
}]
});

// Should include only key values with nullTag=\0
expect(loadWithNullTag.has("keyWithNullTag")).true;
expect(loadWithNullTag.get("keyWithNullTag")).eq("valueWithNullTag");

// Should exclude key values with other tags
expect(loadWithNullTag.has("keyWithEmptyTag")).false;
});

it("should filter by tags with escaped comma characters", async () => {
const connectionString = createMockedConnectionString();
const loadWithEscapedComma = await load(connectionString, {
selectors: [{
keyFilter: "*",
tagFilters: ["tag=value\\,with\\,commas"]
}]
});

expect(loadWithEscapedComma.has("keyWithEscapedComma")).true;
expect(loadWithEscapedComma.get("keyWithEscapedComma")).eq("valueWithEscapedComma");
});

it("should filter by empty tag value to load key values with empty tag", async () => {
const connectionString = createMockedConnectionString();
const loadWithEmptyTag = await load(connectionString, {
selectors: [{
keyFilter: "*",
tagFilters: ["emptyTag="]
}]
});

// Should include key values with emptyTag=""
expect(loadWithEmptyTag.has("keyWithEmptyTag")).true;
expect(loadWithEmptyTag.get("keyWithEmptyTag")).eq("valueWithEmptyTag");

// Should exclude key values with other tags
expect(loadWithEmptyTag.has("keyWithNullTag")).false;
});

it("should also work with other ReadonlyMap APIs", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
Expand Down Expand Up @@ -319,12 +378,40 @@ describe("load", function () {
const loadWithInvalidTagFilter = load(connectionString, {
selectors: [{
keyFilter: "*",
tagFilters: ["testTag"]
tagFilters: ["emptyTag"]
}]
});
return expect(loadWithInvalidTagFilter).to.eventually.rejectedWith("Tag filter must follow the format \"tagName=tagValue\"");
});

it("should throw exception when too many tag filters are provided", async () => {
const connectionString = createMockedConnectionString();

// Create a list with more than the maximum allowed tag filters (assuming max is 5)
const tooManyTagFilters = [
"Environment=Development",
"Team=Backend",
"Priority=High",
"Version=1.0",
"Stage=Testing",
"Region=EastUS" // This should exceed the limit
];
try {
await load(connectionString, {
selectors: [{
keyFilter: "*",
tagFilters: tooManyTagFilters
}]
});
} catch (error) {
expect(error.message).eq("Failed to load.");
expect(error.cause.message).eq("Invalid request parameter 'tags'. Maximum number of tag filters is 5.");
return;
}
// we should never reach here, load should throw an error
throw new Error("Expected load to throw.");
});

it("should override config settings with same key but different label", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
Expand Down
77 changes: 76 additions & 1 deletion test/refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ describe("dynamic refresh", function () {
mockedKVs = [
{ value: "red", key: "app.settings.fontColor" },
{ value: "40", key: "app.settings.fontSize" },
{ value: "30", key: "app.settings.fontSize", label: "prod" }
{ value: "30", key: "app.settings.fontSize", label: "prod" },
{ value: "someValue", key: "TestTagKey", tags: { "env": "dev" } }
].map(createMockedKeyValue);
mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvCallback);
mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback);
Expand Down Expand Up @@ -435,6 +436,34 @@ describe("dynamic refresh", function () {
expect(getKvRequestCount).eq(1);
expect(settings.get("app.settings.fontColor")).eq("blue");
});

it("should refresh key values using tag filters", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
selectors: [{
keyFilter: "*",
tagFilters: ["env=dev"]
}],
refreshOptions: {
enabled: true,
refreshIntervalInMs: 2000
}
});

expect(settings).not.undefined;

// Verify only dev-tagged items are loaded
expect(settings.get("TestTagKey")).eq("someValue");

// Change the dev-tagged key value
updateSetting("TestTagKey", "newValue");

await sleepInMs(2 * 1000 + 1);
await settings.refresh();

// Verify changes are reflected
expect(settings.get("TestTagKey")).eq("newValue");
});
});

describe("dynamic refresh feature flags", function () {
Expand Down Expand Up @@ -549,4 +578,50 @@ describe("dynamic refresh feature flags", function () {
expect(getKvRequestCount).eq(0);
expect(refreshSuccessfulCount).eq(1); // change in feature flags, because page etags are different.
});

it("should refresh feature flags using tag filters", async () => {
mockedKVs = [
createMockedFeatureFlag("DevFeature", { enabled: true }, { tags: { "env": "dev" } }),
createMockedFeatureFlag("ProdFeature", { enabled: false }, { tags: { "env": "prod" } })
];
mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvCallback);
mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback);

const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
featureFlagOptions: {
enabled: true,
selectors: [{
keyFilter: "*",
tagFilters: ["env=dev"]
}],
refresh: {
enabled: true,
refreshIntervalInMs: 2000
}
}
});

expect(settings).not.undefined;

const featureManagement = settings.get<any>("feature_management");
expect(featureManagement).not.undefined;
expect(featureManagement.feature_flags).not.undefined;
expect(featureManagement.feature_flags.length).eq(1);
expect(featureManagement.feature_flags[0].id).eq("DevFeature");
expect(featureManagement.feature_flags[0].enabled).eq(true);

// Change the dev-tagged feature flag
updateSetting(".appconfig.featureflag/DevFeature", JSON.stringify({
"id": "DevFeature",
"enabled": false
}));

await sleepInMs(2 * 1000 + 1);
await settings.refresh();

const updatedFeatureManagement = settings.get<any>("feature_management");
expect(updatedFeatureManagement.feature_flags[0].id).eq("DevFeature");
expect(updatedFeatureManagement.feature_flags[0].enabled).eq(false);
});
});
5 changes: 5 additions & 0 deletions test/utils/testHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) {
const keyFilter = listOptions?.keyFilter ?? "*";
const labelFilter = listOptions?.labelFilter ?? "*";
const tagsFilter = listOptions?.tagsFilter ?? [];

if (tagsFilter.length > 5) {
throw new RestError("Invalid request parameter 'tags'. Maximum number of tag filters is 5.", { statusCode: 400 });
}

return unfilteredKvs.filter(kv => {
const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter;
let labelMatched = false;
Expand Down