Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c035f89
support feature flags
Eskibear Apr 16, 2024
6730571
Apply suggestions from code review
Eskibear Jun 5, 2024
e269596
fix default refresh interval in comments
Eskibear Jun 5, 2024
2d0c820
Merge branch 'main' of github.com:Azure/AppConfiguration-JavaScriptPr…
Eskibear Jun 11, 2024
708f759
update constants to SNAKE_CASE
Eskibear Jun 11, 2024
7f62f14
Merge branch 'main' into yanzh/fm
Eskibear Jun 11, 2024
3cd5f59
use extracted list api
Eskibear Jun 11, 2024
923c124
throw error if selectors not provided
Eskibear Jun 12, 2024
8e2de54
parse feature flag directly
Eskibear Jun 14, 2024
1182638
refactor: rename validateSelectors to getValidSelectors
Eskibear Jun 19, 2024
99f2c29
refresh APIs apply for both kv and ff
Eskibear Jun 19, 2024
dc4f553
dedup loaded feature flags among selectors
Eskibear Jun 20, 2024
c29d834
Merge branch 'main' of github.com:Azure/AppConfiguration-JavaScriptPr…
Eskibear Jun 20, 2024
6411a30
extract requestTraceOptions as private member
Eskibear Jun 24, 2024
4ec140a
list feature flags with pageEtags
Eskibear Jul 2, 2024
453dad3
update mocked client to support byPage iterator
Eskibear Jul 3, 2024
e4eedc9
handle page deletion
Eskibear Jul 3, 2024
87316f3
upgrade @azure/app-configuration to 1.6.1
Eskibear Jul 19, 2024
1499cbf
Merge branch 'main' of github.com:Azure/AppConfiguration-JavaScriptPr…
Eskibear Jul 19, 2024
0c8a4f2
add tests for pageEtag based refresh
Eskibear Jul 19, 2024
a3f068e
Merge branch 'main' of github.com:Azure/AppConfiguration-JavaScriptPr…
Eskibear Jul 29, 2024
fb56a0f
remove pageCount as service ensure etag changes on page add/delete
Eskibear Jul 29, 2024
b1cf809
reset timer on 304, maintain refresh interval gap between attempts
Eskibear Jul 29, 2024
3c12654
use empty string as placeholder when page etag is undefined, to keep …
Eskibear Jul 29, 2024
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
110 changes: 102 additions & 8 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, isFeatureFlag } from "@azure/app-configuration";
import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, FeatureFlagValue, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag, parseFeatureFlag } from "@azure/app-configuration";
import { RestError } from "@azure/core-rest-pipeline";
import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration";
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions";
Expand All @@ -14,6 +14,7 @@ import { RefreshTimer } from "./refresh/RefreshTimer";
import { CorrelationContextHeaderName } from "./requestTracing/constants";
import { createCorrelationContextHeader, requestTracingEnabled } from "./requestTracing/utils";
import { KeyFilter, LabelFilter, SettingSelector } from "./types";
import { featureFlagsKeyName, featureManagementKeyName } from "./featureManagement/constants";

export class AzureAppConfigurationImpl implements AzureAppConfiguration {
/**
Expand Down Expand Up @@ -41,6 +42,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
#sentinels: ConfigurationSettingId[] = [];
#refreshTimer: RefreshTimer;

// Feature flags
#featureFlagRefreshInterval: number = DefaultRefreshIntervalInMs;
#featureFlagRefreshTimer: RefreshTimer;

constructor(
client: AppConfigurationClient,
options: AzureAppConfigurationOptions | undefined
Expand Down Expand Up @@ -85,13 +90,27 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
this.#refreshTimer = new RefreshTimer(this.#refreshInterval);
}

// TODO: should add more adapters to process different type of values
// feature flag, others
// feature flag options
if (options?.featureFlagOptions?.enabled && options.featureFlagOptions.refresh?.enabled) {
const { refreshIntervalInMs } = options.featureFlagOptions.refresh;

// custom refresh interval
if (refreshIntervalInMs !== undefined) {
if (refreshIntervalInMs < MinimumRefreshIntervalInMs) {
throw new Error(`The feature flag refresh interval cannot be less than ${MinimumRefreshIntervalInMs} milliseconds.`);
} else {
this.#featureFlagRefreshInterval = refreshIntervalInMs;
}
}

this.#featureFlagRefreshTimer = new RefreshTimer(this.#featureFlagRefreshInterval);
}

this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions));
this.#adapters.push(new JsonKeyValueAdapter());
}

// ReadonlyMap APIs
// #region ReadonlyMap APIs
get<T>(key: string): T | undefined {
return this.#configMap.get(key);
}
Expand Down Expand Up @@ -123,11 +142,20 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
[Symbol.iterator](): IterableIterator<[string, any]> {
return this.#configMap[Symbol.iterator]();
}
// #endregion

get #refreshEnabled(): boolean {
return !!this.#options?.refreshOptions?.enabled;
}

get #featureFlagEnabled(): boolean {
return !!this.#options?.featureFlagOptions?.enabled;
}

get #featureFlagRefreshEnabled(): boolean {
return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled;
}

async #loadSelectedKeyValues(): Promise<ConfigurationSetting[]> {
const loadedSettings: ConfigurationSetting[] = [];

Expand Down Expand Up @@ -195,17 +223,58 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
keyValues.push([key, value]);
}

this.#configMap.clear(); // clear existing key-values in case of configuration setting deletion
this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion
for (const [k, v] of keyValues) {
this.#configMap.set(k, v);
}
}

async #clearLoadedKeyValues() {
for(const key of this.#configMap.keys()) {
if (key !== featureManagementKeyName) {
this.#configMap.delete(key);
}
}
}

async #loadFeatureFlags() {
const featureFlags: FeatureFlagValue[] = [];
const featureFlagSelectors = getValidSelectors(this.#options?.featureFlagOptions?.selectors);
for (const selector of featureFlagSelectors) {
const listOptions: ListConfigurationSettingsOptions = {
keyFilter: `${featureFlagPrefix}${selector.keyFilter}`,
labelFilter: selector.labelFilter
};
if (this.#requestTracingEnabled) {
listOptions.requestOptions = {
customHeaders: {
[CorrelationContextHeaderName]: createCorrelationContextHeader(this.#options, this.#isInitialLoadCompleted)
}
}
}

const settings = this.#client.listConfigurationSettings(listOptions);

for await (const setting of settings) {
if (isFeatureFlag(setting)) {
const flag = parseFeatureFlag(setting);
featureFlags.push(flag.value)
}
}
}

// feature_management is a reserved key, and feature_flags is an array of feature flags
this.#configMap.set(featureManagementKeyName, { [featureFlagsKeyName]: featureFlags });
}

/**
* Load the configuration store for the first time.
*/
async load() {
await this.#loadSelectedAndWatchedKeyValues();
if (this.#featureFlagEnabled) {
await this.#loadFeatureFlags();
}
// Mark all settings have loaded at startup.
this.#isInitialLoadCompleted = true;
}
Expand Down Expand Up @@ -257,10 +326,19 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
* Refresh the configuration store.
*/
async refresh(): Promise<void> {
if (!this.#refreshEnabled) {
throw new Error("Refresh is not enabled.");
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
throw new Error("Refresh is not enabled for key-values and feature flags.");
}

if (this.#refreshEnabled) {
await this.#refreshKeyValues();
}
if (this.#featureFlagRefreshEnabled) {
await this.#refreshFeatureFlags();
}
}

async #refreshKeyValues(): Promise<void> {
// if still within refresh interval/backoff, return
if (!this.#refreshTimer.canRefresh()) {
return Promise.resolve();
Expand Down Expand Up @@ -298,9 +376,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}
}

async #refreshFeatureFlags(): Promise<void> {
// if still within refresh interval/backoff, return
if (!this.#featureFlagRefreshTimer.canRefresh()) {
return Promise.resolve();
}

try {
await this.#loadFeatureFlags();
this.#featureFlagRefreshTimer.reset();
} catch (error) {
// if refresh failed, backoff
this.#featureFlagRefreshTimer.backoff();
throw error;
}
}

onRefresh(listener: () => any, thisArg?: any): Disposable {
if (!this.#refreshEnabled) {
throw new Error("Refresh is not enabled.");
throw new Error("Refresh is not enabled for key-values.");
}

const boundedListener = listener.bind(thisArg);
Expand Down
7 changes: 7 additions & 0 deletions src/AzureAppConfigurationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AppConfigurationClientOptions } from "@azure/app-configuration";
import { KeyVaultOptions } from "./keyvault/KeyVaultOptions";
import { RefreshOptions } from "./RefreshOptions";
import { SettingSelector } from "./types";
import { FeatureFlagOptions } from "./featureManagement/FeatureFlagOptions";

export const MaxRetries = 2;
export const MaxRetryDelayInMs = 60000;
Expand Down Expand Up @@ -36,8 +37,14 @@ export interface AzureAppConfigurationOptions {
* Specifies options used to resolve Vey Vault references.
*/
keyVaultOptions?: KeyVaultOptions;

/**
* Specifies options for dynamic refresh key-values.
*/
refreshOptions?: RefreshOptions;

/**
* Specifies options used to configure feature flags.
*/
featureFlagOptions?: FeatureFlagOptions;
}
14 changes: 14 additions & 0 deletions src/RefreshOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,17 @@ export interface RefreshOptions {
*/
watchedSettings?: WatchedSetting[];
}

export interface FeatureFlagRefreshOptions {
/**
* Specifies whether the provider should automatically refresh when the configuration is changed.
*/
enabled: boolean;

/**
* Specifies the minimum time that must elapse before checking the server for any new changes.
* Default value is 10 seconds. Must be greater than 1 second.
* Any refresh operation triggered will not update the value for a key until after the interval.
*/
refreshIntervalInMs?: number;
}
29 changes: 29 additions & 0 deletions src/featureManagement/FeatureFlagOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { FeatureFlagRefreshOptions } from "../RefreshOptions";
import { SettingSelector } from "../types";

/**
* Options used to configure feature flags.
*/
export interface FeatureFlagOptions {
/**
* Specifies whether feature flag support is enabled.
*/
enabled: boolean;

/**
* Specifies the selectors used to filter feature flags.
*
* @remarks
* keyFilter of selector will be prefixed with "appconfig.featureflag/" when request is sent.
* If no selectors are specified then no feature flags will be retrieved.
Copy link
Member

Choose a reason for hiding this comment

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

If no selectors are specified, shouldn't we load all Feature Flags with null label? That's what we do for key-values too.

Copy link
Member Author

Choose a reason for hiding this comment

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

Per discussion, for feature flags we expect customers to explicitly specify selectors.

Copy link
Member

Choose a reason for hiding this comment

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

Should we throw an error if FeatureFlagOptions.enabled is true but selectors are not provided?

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree, as it makes no sense to enable feature flags but load nothing. Throwing an error is better than silently loading nothing.

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

*/
selectors?: SettingSelector[];

/**
* Specifies how feature flag refresh is configured. All selected feature flags will be watched for changes.
*/
refresh?: FeatureFlagRefreshOptions;
}
5 changes: 5 additions & 0 deletions src/featureManagement/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

export const featureManagementKeyName = "feature_management";
export const featureFlagsKeyName = "feature_flags";
77 changes: 77 additions & 0 deletions test/featureFlag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import * as chai from "chai";
import * as chaiAsPromised from "chai-as-promised";
import { load } from "./exportedApi";
import { createMockedConnectionString, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper";
chai.use(chaiAsPromised);
const expect = chai.expect;

const mockedKVs = [{
key: "app.settings.fontColor",
value: "red",
}].map(createMockedKeyValue).concat([
createMockedFeatureFlag("Beta", true),
createMockedFeatureFlag("Alpha_1", true),
createMockedFeatureFlag("Alpha2", false),
]);

describe("feature flags", function () {
this.timeout(10000);

before(() => {
mockAppConfigurationClientListConfigurationSettings(mockedKVs);
});

after(() => {
restoreMocks();
})
it("should load feature flags if enabled", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
featureFlagOptions: {
enabled: true
}
});
expect(settings).not.undefined;
expect(settings.get("feature_management")).not.undefined;
expect(settings.get<any>("feature_management").feature_flags).not.undefined;
});

it("should not load feature flags if disabled", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
featureFlagOptions: {
enabled: false
}
});
expect(settings).not.undefined;
expect(settings.get("feature_management")).undefined;
});

it("should not load feature flags if not specified", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString);
expect(settings).not.undefined;
expect(settings.get("feature_management")).undefined;
});

it("should load feature flags with custom selector", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
featureFlagOptions: {
enabled: true,
selectors: [{
keyFilter: "Alpha*"
}]
}
});
expect(settings).not.undefined;
expect(settings.get("feature_management")).not.undefined;
const featureFlags = settings.get<any>("feature_management").feature_flags;
expect(featureFlags).not.undefined;
expect((featureFlags as []).length).equals(2);
});

});
Loading