Skip to content
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

Support load snapshot #140

Open
wants to merge 6 commits into
base: release/v2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
141 changes: 108 additions & 33 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag } from "@azure/app-configuration";
import {
AppConfigurationClient,
ConfigurationSetting,
ConfigurationSettingId,
GetConfigurationSettingOptions,
GetConfigurationSettingResponse,
ListConfigurationSettingsOptions,
featureFlagPrefix,
isFeatureFlag,
GetSnapshotOptions,
GetSnapshotResponse,
KnownSnapshotComposition
} from "@azure/app-configuration";
import { isRestError } from "@azure/core-rest-pipeline";
import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration.js";
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js";
Expand Down Expand Up @@ -35,7 +47,14 @@ import {
} from "./featureManagement/constants.js";
import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js";
import { RefreshTimer } from "./refresh/RefreshTimer.js";
import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js";
import {
RequestTracingOptions,
getConfigurationSettingWithTrace,
listConfigurationSettingsWithTrace,
getSnapshotWithTrace,
listConfigurationSettingsForSnapshotWithTrace,
requestTracingEnabled
} from "./requestTracing/utils.js";
import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js";
import { KeyFilter, LabelFilter, SettingSelector } from "./types.js";
import { ConfigurationClientManager } from "./ConfigurationClientManager.js";
Expand Down Expand Up @@ -363,26 +382,49 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
);

for (const selector of selectorsToUpdate) {
const listOptions: ListConfigurationSettingsOptions = {
keyFilter: selector.keyFilter,
labelFilter: selector.labelFilter
};

const pageEtags: string[] = [];
const pageIterator = listConfigurationSettingsWithTrace(
this.#requestTraceOptions,
client,
listOptions
).byPage();
for await (const page of pageIterator) {
pageEtags.push(page.etag ?? "");
for (const setting of page.items) {
if (loadFeatureFlag === isFeatureFlag(setting)) {
loadedSettings.push(setting);
if (selector.snapshotName === undefined) {
const listOptions: ListConfigurationSettingsOptions = {
keyFilter: selector.keyFilter,
labelFilter: selector.labelFilter
};
const pageEtags: string[] = [];
const pageIterator = listConfigurationSettingsWithTrace(
this.#requestTraceOptions,
client,
listOptions
).byPage();

for await (const page of pageIterator) {
pageEtags.push(page.etag ?? "");
for (const setting of page.items) {
if (loadFeatureFlag === isFeatureFlag(setting)) {
loadedSettings.push(setting);
}
}
}
selector.pageEtags = pageEtags;
} else { // snapshot selector
const snapshot = await this.#getSnapshot(selector.snapshotName);
if (snapshot === undefined) {
throw new Error(`Could not find snapshot with name ${selector.snapshotName}.`);
}
if (snapshot.compositionType != KnownSnapshotComposition.Key) {
throw new Error(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`);
}
const pageIterator = listConfigurationSettingsForSnapshotWithTrace(
this.#requestTraceOptions,
client,
selector.snapshotName
).byPage();

for await (const page of pageIterator) {
for (const setting of page.items) {
if (loadFeatureFlag === isFeatureFlag(setting)) {
loadedSettings.push(setting);
}
}
}
}
selector.pageEtags = pageEtags;
}

if (loadFeatureFlag) {
Expand Down Expand Up @@ -530,6 +572,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise<boolean> {
const funcToExecute = async (client) => {
for (const selector of selectors) {
if (selector.snapshotName) { // skip snapshot selector
continue;
}
const listOptions: ListConfigurationSettingsOptions = {
keyFilter: selector.keyFilter,
labelFilter: selector.labelFilter,
Expand Down Expand Up @@ -581,6 +626,29 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
return response;
}

async #getSnapshot(snapshotName: string, customOptions?: GetSnapshotOptions): Promise<GetSnapshotResponse | undefined> {
const funcToExecute = async (client) => {
return getSnapshotWithTrace(
this.#requestTraceOptions,
client,
snapshotName,
customOptions
);
};

let response: GetSnapshotResponse | undefined;
try {
response = await this.#executeWithFailoverPolicy(funcToExecute);
} catch (error) {
if (isRestError(error) && error.statusCode === 404) {
response = undefined;
} else {
throw error;
}
}
return response;
}

async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise<any>): Promise<any> {
let clientWrappers = await this.#clientManager.getClients();
if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) {
Expand Down Expand Up @@ -862,11 +930,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}
}

function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {
// below code deduplicates selectors by keyFilter and labelFilter, the latter selector wins
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);
const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter && s.snapshotName === selector.snapshotName);
if (existingSelectorIndex >= 0) {
uniqueSelectors.splice(existingSelectorIndex, 1);
}
Expand All @@ -875,14 +943,20 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {

return uniqueSelectors.map(selectorCandidate => {
const selector = { ...selectorCandidate };
if (!selector.keyFilter) {
throw new Error("Key filter cannot be null or empty.");
}
if (!selector.labelFilter) {
selector.labelFilter = LabelFilter.Null;
}
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
throw new Error("The characters '*' and ',' are not supported in label filters.");
if (selector.snapshotName) {
if (selector.keyFilter || selector.labelFilter) {
throw new Error("Key or label filter should not be used for a snapshot.");
}
} else {
if (!selector.keyFilter) {
throw new Error("Key filter cannot be null or empty.");
}
if (!selector.labelFilter) {
selector.labelFilter = LabelFilter.Null;
}
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
throw new Error("The characters '*' and ',' are not supported in label filters.");
}
}
return selector;
});
Expand All @@ -893,18 +967,19 @@ function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelect
// Default selector: key: *, label: \0
return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }];
}
return getValidSelectors(selectors);
return getValidSettingSelectors(selectors);
}

function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] {
if (selectors === undefined || selectors.length === 0) {
// selectors must be explicitly provided.
throw new Error("Feature flag selectors must be provided.");
} else {
selectors.forEach(selector => {
const validSelectors = getValidSettingSelectors(selectors);
validSelectors.forEach(selector => {
selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`;
});
return getValidSelectors(selectors);
return validSelectors;
}
}

Expand Down
47 changes: 31 additions & 16 deletions src/requestTracing/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions } from "@azure/app-configuration";
import { OperationOptions } from "@azure/core-client";
import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions, GetSnapshotOptions, ListConfigurationSettingsForSnapshotOptions } from "@azure/app-configuration";
import { AzureAppConfigurationOptions } from "../AzureAppConfigurationOptions.js";
import { FeatureFlagTracingOptions } from "./FeatureFlagTracingOptions.js";
import {
Expand Down Expand Up @@ -45,15 +46,7 @@ export function listConfigurationSettingsWithTrace(
client: AppConfigurationClient,
listOptions: ListConfigurationSettingsOptions
) {
const actualListOptions = { ...listOptions };
if (requestTracingOptions.enabled) {
actualListOptions.requestOptions = {
customHeaders: {
[CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions)
}
};
}

const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions);
return client.listConfigurationSettings(actualListOptions);
}

Expand All @@ -63,20 +56,43 @@ export function getConfigurationSettingWithTrace(
configurationSettingId: ConfigurationSettingId,
getOptions?: GetConfigurationSettingOptions,
) {
const actualGetOptions = { ...getOptions };
const actualGetOptions = applyRequestTracing(requestTracingOptions, getOptions);
return client.getConfigurationSetting(configurationSettingId, actualGetOptions);
}

export function getSnapshotWithTrace(
requestTracingOptions: RequestTracingOptions,
client: AppConfigurationClient,
snapshotName: string,
getOptions?: GetSnapshotOptions
) {
const actualGetOptions = applyRequestTracing(requestTracingOptions, getOptions);
return client.getSnapshot(snapshotName, actualGetOptions);
}

export function listConfigurationSettingsForSnapshotWithTrace(
requestTracingOptions: RequestTracingOptions,
client: AppConfigurationClient,
snapshotName: string,
listOptions?: ListConfigurationSettingsForSnapshotOptions
) {
const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions);
return client.listConfigurationSettingsForSnapshot(snapshotName, actualListOptions);
}

function applyRequestTracing<T extends OperationOptions>(requestTracingOptions: RequestTracingOptions, operationOptions?: T) {
const actualOptions = { ...operationOptions };
if (requestTracingOptions.enabled) {
actualGetOptions.requestOptions = {
actualOptions.requestOptions = {
customHeaders: {
[CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions)
}
};
}

return client.getConfigurationSetting(configurationSettingId, actualGetOptions);
return actualOptions;
}

export function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string {
function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string {
/*
RequestType: 'Startup' during application starting up, 'Watch' after startup completed.
Host: identify with defined envs
Expand Down Expand Up @@ -200,4 +216,3 @@ export function isWebWorker() {

return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected;
}

11 changes: 10 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type SettingSelector = {
* For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\).
* e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`.
*/
keyFilter: string,
keyFilter?: string,

/**
* The label filter to apply when querying Azure App Configuration for key-values.
Expand All @@ -29,6 +29,15 @@ export type SettingSelector = {
* @defaultValue `LabelFilter.Null`, matching key-values without a label.
*/
labelFilter?: string

/**
* The name of snapshot to load from App Configuration.
*
* @remarks
* Snapshot is a set of key-values selected from the App Configuration store based on the composition type and filters. Once created, it is stored as an immutable entity that can be referenced by name.
* If snapshot name is used in a selector, no key and label filter should be used for it. Otherwise, an exception will be thrown.
*/
snapshotName?: string
};

/**
Expand Down
30 changes: 29 additions & 1 deletion test/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised";
chai.use(chaiAsPromised);
const expect = chai.expect;
import { load } from "./exportedApi.js";
import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js";
import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetSnapshot, mockAppConfigurationClientListConfigurationSettingsForSnapshot, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js";

const mockedKVs = [{
key: "app.settings.fontColor",
Expand Down Expand Up @@ -418,4 +418,32 @@ describe("load", function () {
settings.constructConfigurationObject({ separator: "%" });
}).to.throw("Invalid separator '%'. Supported values: '.', ',', ';', '-', '_', '__', '/', ':'.");
});

it("should load from snapshot", async () => {
const snapshotName = "Test";
mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"});
mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)]);
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
selectors: [{
snapshotName: snapshotName
}]
});
expect(settings).not.undefined;
expect(settings).not.undefined;
expect(settings.get("TestKey")).eq("TestValue");
restoreMocks();
});

it("should throw error when snapshot composition type is not key", async () => {
const snapshotName = "Test";
mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key_label"});
const connectionString = createMockedConnectionString();
expect(load(connectionString, {
selectors: [{
snapshotName: snapshotName
}]
})).eventually.rejectedWith(`Composition type for the selected snapshot with name ${snapshotName} must be 'key'.`);
restoreMocks();
});
});
Loading
Loading