Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { ImportMode } from "./enums";
import { OperationTimeoutError, ArgumentError } from "./errors";
import { AdaptiveTaskManager } from "./internal/adaptiveTaskManager";
import { ImportProgress, KeyLabelLookup } from "./models";
import { ImportProgress, KeyLabelLookup, ConfigurationChanges } from "./models";
import { isConfigSettingEqual } from "./internal/utils";
import { v4 as uuidv4 } from "uuid";
import { Constants } from "./internal/constants";
Expand Down Expand Up @@ -43,27 +43,72 @@
* @param timeout - Seconds of entire import progress timeout
* @param progressCallback - Callback for report the progress of import
* @param importMode - Determines the behavior when importing key-values. The default value, 'All' will import all key-values in the input file to App Configuration. 'Ignore-Match' will only import settings that have no matching key-value in App Configuration.
* @param dryRun - When dry run is enabled, no updates will be performed to App Configuration. Instead, any updates that would have been performed in a normal run will be printed to the console for review
* @returns void
*/
public async Import(
configSettingsSource: ConfigurationSettingsSource,
timeout: number,
strict = false,
progressCallback?: (progress: ImportProgress) => unknown,
importMode?: ImportMode,
dryRun?: boolean
) {
importMode?: ImportMode
): Promise<void> {
if (importMode == undefined) {
importMode = ImportMode.IgnoreMatch;
}
if (dryRun == undefined) {
dryRun = false;

Copy link
Member

Choose a reason for hiding this comment

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

[nitpick] Unnecessary blank line

this.validateImportMode(importMode);

// Generate correlation ID for operations
Copy link
Member

Choose a reason for hiding this comment

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

// Generate correlationRequestId for operations in the same activity

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated

const customCorrelationRequestId: string = uuidv4();
const customHeadersOption: OperationOptions = {
requestOptions: {
customHeaders: {
[Constants.CorrelationRequestIdHeader]: customCorrelationRequestId
}
}
};

const configurationChanges: ConfigurationChanges = await this.getConfigurationChanges(configSettingsSource, strict, importMode, customHeadersOption);

await this.applyUpdatesToServer([...configurationChanges.Added, ...configurationChanges.Modified], configurationChanges.Deleted, timeout, customHeadersOption, progressCallback);
}

/**
* Get configuration changes between source settings and Azure App Configuration service without any applying changes
Copy link
Contributor

Choose a reason for hiding this comment

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

What does it mean "without any applying changes" ? What do you think of "Get configuration changes between source settings and existing settings in Azure App Configuration service...`

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 meant - "without applying any changes". Don't you think its beneficial to mention this so that users can understand they can call it safely just to preview changes?

Copy link
Contributor Author

@ChristineWanjau ChristineWanjau Nov 20, 2025

Choose a reason for hiding this comment

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

Suggesting -"Get configuration changes between source settings and existing settings in Azure App Configuration service without applying any changes"

Copy link
Contributor

Choose a reason for hiding this comment

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

Sounds good to me

*
* Example usage:
* ```ts
* const fileData = fs.readFileSync("mylocalPath").toString();
* const configurationChanges = await client.getConfigurationChanges(
* new StringConfigurationSettingsSource({data:fileData, format: ConfigurationFormat.Json}),
* false,
* ImportMode.All,
* options
* );
* ```
* @param configSettingsSource - A ConfigurationSettingsSource instance.
* @param strict - Use strict mode to delete settings not in source.
* @param importMode - Determines the behavior when analyzing key-values. 'All' will include all key-values. 'Ignore-Match' will exclude settings that have matching key-values in App Configuration.
* @param customHeadersOption - Custom headers for the operation.
* @returns ConfigurationChanges object containing Added, Modified, and Deleted settings
*/
public async getConfigurationChanges(
Copy link
Member

Choose a reason for hiding this comment

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

Should the first letter be upper case given this is a public function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, updated thank you

configSettingsSource: ConfigurationSettingsSource,
strict = false,
importMode?: ImportMode,
customHeadersOption?: OperationOptions
Copy link
Member

Choose a reason for hiding this comment

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

How is this used in this function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The customHeadersOptions?

Copy link
Member

Choose a reason for hiding this comment

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

Right. It does look like needed for this function.

Copy link
Contributor Author

@ChristineWanjau ChristineWanjau Nov 12, 2025

Choose a reason for hiding this comment

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

We use customHeadersOption to pass the correlationRequestId to link requests involved in the Import process. When the Import API calls the GetConfigurationChanges API, we pass the same correlationRequestId to associate both the read and write operations.
Additionally, we could use this to link the call to request the diff and Import on the portal for better traceability.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Discussed offline. Updated to generate correlationrequestid if not passed to GetConfigurationChanges

): Promise<ConfigurationChanges> {
if (importMode == undefined) {
importMode = ImportMode.IgnoreMatch;
}

this.validateImportMode(importMode);

const configSettings = await configSettingsSource.GetConfigurationSettings();

const configurationSettingToDelete: ConfigurationSetting<string>[] = [];
const configurationSettingToModify: SetConfigurationSettingParam<string | FeatureFlagValue | SecretReferenceValue>[] = [];
const configurationSettingToAdd: SetConfigurationSettingParam<string | FeatureFlagValue | SecretReferenceValue>[] = [];
const srcKeyLabelLookUp: KeyLabelLookup = {};

configSettings.forEach((config: SetConfigurationSettingParam<string | FeatureFlagValue | SecretReferenceValue>) => {
Expand All @@ -73,59 +118,45 @@
srcKeyLabelLookUp[config.key][config.label || ""] = true;
});

// generate correlationRequestId for operations in the same activity
const customCorrelationRequestId: string = uuidv4();
const customHeadersOption: OperationOptions = {
requestOptions: {
customHeaders: {
[Constants.CorrelationRequestIdHeader]: customCorrelationRequestId
}
}
};

if (strict || importMode == ImportMode.IgnoreMatch) {
for await (const existing of this.configurationClient.listConfigurationSettings({...configSettingsSource.FilterOptions, ...customHeadersOption})) {
configurationSettingToAdd.push(...configSettings);

const isKeyLabelPresent: boolean = srcKeyLabelLookUp[existing.key] && srcKeyLabelLookUp[existing.key][existing.label || ""];
for await (const existing of this.configurationClient.listConfigurationSettings({...configSettingsSource.FilterOptions, ...customHeadersOption})) {
const isKeyLabelPresent: boolean = srcKeyLabelLookUp[existing.key] && srcKeyLabelLookUp[existing.key][existing.label || ""];
if (strict && !isKeyLabelPresent) {
configurationSettingToDelete.push(existing);
}

const incoming = configSettings.find(configSetting => configSetting.key == existing.key &&
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd like to suggest instead of adding settings then removing them, maybe we shouldn't add them in the first place, maybe we can have something like this

if (importMode == ImportMode.IgnoreMatch) { 
    const incoming = configSettings.find(configSetting => configSetting.key == existing.key && 
            configSetting.label == existing.label);
    if (!incoming) { 
       configSettingToAdd.push(existing);
  } else if (incoming && !isConfigSettingEqual(incoming, existing)) { 
   configSettingToModify.push(existing);
 } 
}

Doesn't have to be exactly like this just an idea

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a bit tricky to add settings in the loop because we are looping through existing ones. I have made updates to the logic please let me know what you think

configSetting.label == existing.label);

if (incoming) {
const settingsAreEqual: boolean = isConfigSettingEqual(incoming, existing);

if (strict && !isKeyLabelPresent) {
configurationSettingToDelete.push(existing);
if (!settingsAreEqual) {
configurationSettingToModify.push(incoming);
// Remove from add list since it's a modification, not an addition
const addIndex: number = configurationSettingToAdd.findIndex(addSetting =>
addSetting.key === incoming.key && addSetting.label === incoming.label);
if (addIndex !== -1) {
configurationSettingToAdd.splice(addIndex, 1);
}
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Inefficient array operations: for each existing setting that differs, the code performs a findIndex followed by splice on the configurationSettingToAdd array. With N incoming settings and M existing settings that differ, this results in O(N*M) complexity. Consider using a Set or Map data structure to track which settings should be added vs modified, or remove from the add list during the initial loop rather than searching for it later.

Copilot uses AI. Check for mistakes.
}

if (importMode == ImportMode.IgnoreMatch) {
const incoming = configSettings.find(configSetting => configSetting.key == existing.key &&
configSetting.label == existing.label);

if (incoming && isConfigSettingEqual(incoming, existing)) {
configSettings.splice(configSettings.indexOf(incoming), 1);
else if (importMode == ImportMode.IgnoreMatch) {
// Remove unchanged settings from add list
const addIndex = configurationSettingToAdd.findIndex(addSetting =>
addSetting.key === incoming.key && addSetting.label === incoming.label);
if (addIndex !== -1) {
configurationSettingToAdd.splice(addIndex, 1);
}
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

Similar performance issue: this block also performs findIndex and splice operations within a loop. This should be optimized along with the modification logic in lines 123-127.

Copilot uses AI. Check for mistakes.
}
}
}

if (dryRun) {
this.printUpdatesToConsole(configSettings, configurationSettingToDelete);
}
else {
await this.applyUpdatesToServer(configSettings, configurationSettingToDelete, timeout, customHeadersOption, progressCallback);
}
}

private printUpdatesToConsole(
settingsToAdd: SetConfigurationSettingParam<string | FeatureFlagValue | SecretReferenceValue>[],
settingsToDelete: ConfigurationSetting<string>[]
): void {
console.log("The following settings will be removed from App Configuration:");
for (const setting of settingsToDelete) {

console.log(JSON.stringify({key: setting.key, label: setting.label, contentType: setting.contentType, tags: setting.tags}));
}

console.log("\nThe following settings will be written to App Configuration:");
for (const setting of settingsToAdd) {

console.log(JSON.stringify({key: setting.key, label: setting.label, contentType: setting.contentType, tags: setting.tags}));
}
return {
Added: configurationSettingToAdd,
Modified: configurationSettingToModify,
Deleted: configurationSettingToDelete
};
}

private async applyUpdatesToServer(
Copy link
Member

Choose a reason for hiding this comment

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

How about changing the argument name settingsToAdd to settingsToPut since it can involve add and modify?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated

Expand All @@ -146,7 +177,7 @@
await this.executeTasksWithTimeout(importTaskManager, timeout, progressCallback);
}

private newAdaptiveTaskManager<T>(task: (setting: T) => Promise<any>, configurationSettings: Array<T>) {

Check warning on line 180 in libraries/azure-app-configuration-importer/src/appConfigurationImporter.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
let index = 0;
return new AdaptiveTaskManager(() => {
if (index == configurationSettings.length) {
Expand Down
2 changes: 1 addition & 1 deletion libraries/azure-app-configuration-importer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export {
} from "./importOptions";
export * from "./enums";
export * from "./errors";
export { ImportProgress as ImportResult } from "./models";
export { ImportProgress as ImportResult, ConfigurationChanges } from "./models";
export { StringConfigurationSettingsSource } from "./settingsImport/stringConfigurationSettingsSource";
export { ConfigurationSettingsSource } from "./settingsImport/configurationSettingsSource";
export { IterableConfigurationSettingsSource } from "./settingsImport/iterableConfigurationSettingsSource";
Expand Down
13 changes: 13 additions & 0 deletions libraries/azure-app-configuration-importer/src/models.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import {
SecretReferenceValue,
ConfigurationSetting,
SetConfigurationSettingParam,
FeatureFlagValue
} from "@azure/app-configuration";

/**
* @internal
*/
Expand Down Expand Up @@ -42,4 +49,10 @@ export interface KeyLabelLookup {
[key: string]: {
[label: string] : boolean
}
}

export interface ConfigurationChanges {
Copy link
Contributor

Choose a reason for hiding this comment

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

If a config hasn't change, do we also return it as part of the ConfigurationChanges

Copy link
Contributor Author

@ChristineWanjau ChristineWanjau Nov 19, 2025

Choose a reason for hiding this comment

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

Only when import mode is equal to ALL; if a config hasn't changed, then it will be part of the changes

Deleted: ConfigurationSetting<string>[];
Modified: SetConfigurationSettingParam<string | FeatureFlagValue | SecretReferenceValue>[];
Added: SetConfigurationSettingParam<string | FeatureFlagValue | SecretReferenceValue>[];
Copy link
Member

Choose a reason for hiding this comment

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

How about rename these fields to ToDelete ToModify, ToAdd? The current names seem to imply that the change has been made.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense to me. Updated

}
Original file line number Diff line number Diff line change
Expand Up @@ -231,131 +231,88 @@ describe("Call Import API to import configuration file to AppConfiguration", ()
assert.equal(total, 3);
});

describe("Call Import API to import configurations from file and pass dryRun and Import mode options",async()=>{
let spy: sinon.SinonSpy;
describe("Call getConfigurationChanges API to get configurations changes from file and pass Import mode options", () => {
let appConfigurationImporter: AppConfigurationImporter;

beforeEach(function () {
spy = sinon.spy(console,"log");
const AppConfigurationClientStub = sinon.createStubInstance(AppConfigurationClient);
AppConfigurationClientStub.listConfigurationSettings.returns(listConfigurationSettings());
appConfigurationImporter = new AppConfigurationImporter(AppConfigurationClientStub);
});

afterEach(function () {
spy.restore();
});

it("Succeed to import and log all key-values with importMode as All, profile as default", async()=>{
it("Succeed to get configuration changes with importMode as All, profile as default", async () => {
const options = {
data: fs.readFileSync(path.join("__dirname", "../tests/sources/default.json")).toString(),
format: ConfigurationFormat.Json,
profile: ConfigurationProfile.Default,
label: "Dev",
separator: ":"
};

const stringConfigurationSource = new StringConfigurationSettingsSource(options);
let finished = 0;
let total = 0;
const reportImportProgress = (importProgress: ImportProgress) => {
finished = importProgress.successCount;
total = importProgress.importCount;
};
await appConfigurationImporter.Import(stringConfigurationSource, 3, false, reportImportProgress, ImportMode.All, true);

// All key-values in App Configuration will be updated
assert.equal(spy.getCall(1).args[0], "\nThe following settings will be written to App Configuration:");
assert.equal(spy.getCall(2).args[0], "{\"key\":\"app:Settings:FontSize\",\"label\":\"Dev\"}");
assert.equal(spy.getCall(3).args[0], "{\"key\":\"app:Settings:BackgroundColor\",\"label\":\"Dev\"}");
assert.equal(spy.getCall(4).args[0], "{\"key\":\"app:Settings:FontColor\",\"label\":\"Dev\"}");
assert.equal(finished, 0);
assert.equal(total, 0);
const configurationChanges = await appConfigurationImporter.getConfigurationChanges(stringConfigurationSource, false, ImportMode.All);
assert.equal(configurationChanges.Added.length, 2);
assert.equal(configurationChanges.Modified.length, 1);
assert.equal(configurationChanges.Deleted.length, 0);
assert.equal(configurationChanges.Modified[0].key, "app:Settings:FontColor");
});

it("Succeed to import and log no matching key values updates with importMode as IgnoreMatch and profile as default", async()=>{
it("Succeed to get configuration changes and return no matching key values updates with importMode as IgnoreMatch and profile as default", async () => {
const options = {
data: fs.readFileSync(path.join("__dirname", "../tests/sources/default.json")).toString(),
format: ConfigurationFormat.Json,
profile: ConfigurationProfile.Default,
label: "Dev",
separator: ":"
};

const stringConfigurationSource = new StringConfigurationSettingsSource(options);
let finished = 0;
let total = 0;
const reportImportProgress = (importProgress: ImportProgress) => {
finished = importProgress.successCount;
total = importProgress.importCount;
};
await appConfigurationImporter.Import(stringConfigurationSource, 3, false, reportImportProgress, ImportMode.IgnoreMatch, true);

const source = new StringConfigurationSettingsSource(options);
const configurationChanges = await appConfigurationImporter.getConfigurationChanges(source, false, ImportMode.IgnoreMatch);
// Only keys with no matching key-values in App Configuration will be updated
assert.equal(spy.getCall(1).args[0], "\nThe following settings will be written to App Configuration:");
assert.equal(spy.getCall(2).args[0], "{\"key\":\"app:Settings:FontColor\",\"label\":\"Dev\"}");
assert.equal(finished, 0);
assert.equal(total, 0);
assert.equal(configurationChanges.Added.length, 0);
assert.equal(configurationChanges.Modified.length, 1);
assert.equal(configurationChanges.Modified[0].key, "app:Settings:FontColor");
assert.equal(configurationChanges.Deleted.length, 0);
});

it("Succeed to import key-values file with importMode as All and profile as kvset", async()=>{
it("Succeed to get configuration changes from key-values file with importMode as All and profile as kvset", async () => {
const options = {
data: fs.readFileSync(path.join("__dirname", "../tests/sources/kvset.json")).toString(),
format: ConfigurationFormat.Json,
profile: ConfigurationProfile.KvSet
};

const stringConfigurationSource = new StringConfigurationSettingsSource(options);
let finished = 0;
let total = 0;
const reportImportProgress = (importProgress: ImportProgress) => {
finished = importProgress.successCount;
total = importProgress.importCount;
};
await appConfigurationImporter.Import(stringConfigurationSource, 3, false, reportImportProgress, ImportMode.All, true);

const configurationChanges = await appConfigurationImporter.getConfigurationChanges(stringConfigurationSource, false, ImportMode.All);
//All key-values in App Configuration will be updated
assert.equal(spy.getCall(1).args[0], "\nThe following settings will be written to App Configuration:");
assert.equal(spy.getCall(2).args[0], "{\"key\":\".appconfig.featureflag/Test\",\"label\":\"dev\",\"contentType\":\"application/vnd.microsoft.appconfig.ff+json;charset=utf-8\",\"tags\":{}}");
assert.equal(spy.getCall(3).args[0], "{\"key\":\"Database:ConnectionString\",\"label\":\"test\",\"contentType\":\"application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8\",\"tags\":{}}");
assert.equal(spy.getCall(4).args[0], "{\"key\":\"TestEnv\",\"label\":\"dev\",\"contentType\":null,\"tags\":{\"tag1\":\"value1\",\"tag2\":\"value2\"}}");
assert.equal(finished, 0);
assert.equal(total, 0);
assert.equal(configurationChanges.Added.length, 2);
assert.equal(configurationChanges.Modified.length, 1);
assert.equal(configurationChanges.Modified[0].key, "TestEnv");
assert.equal(configurationChanges.Deleted.length, 0);
});

it("Succeed to import key-values and log no matching key values with importMode as IgnoreMatch and profile as kvset", async()=>{
it("Succeed to get configuration changes and return no matching key values with importMode as IgnoreMatch and profile as kvset", async () => {
const options = {
data: fs.readFileSync(path.join("__dirname", "../tests/sources/kvset.json")).toString(),
format: ConfigurationFormat.Json,
profile: ConfigurationProfile.KvSet
};

const stringConfigurationSource = new StringConfigurationSettingsSource(options);
let finished = 0;
let total = 0;
const reportImportProgress = (importProgress: ImportProgress) => {
finished = importProgress.successCount;
total = importProgress.importCount;
};
await appConfigurationImporter.Import(stringConfigurationSource, 3, false, reportImportProgress, ImportMode.IgnoreMatch, true);

//Only keys with no matching key-values in App Configuration will be updated
assert.equal(spy.getCall(1).args[0], "\nThe following settings will be written to App Configuration:");
assert.equal(spy.getCall(2).args[0], "{\"key\":\"TestEnv\",\"label\":\"dev\",\"contentType\":null,\"tags\":{\"tag1\":\"value1\",\"tag2\":\"value2\"}}");
assert.equal(finished, 0);
assert.equal(total, 0);
const source = new StringConfigurationSettingsSource(options);
const configurationChanges = await appConfigurationImporter.getConfigurationChanges(source, false, ImportMode.IgnoreMatch);
// Only changed key (TestEnv) should be in Modified
assert.equal(configurationChanges.Added.length, 0);
assert.equal(configurationChanges.Modified.length, 1);
assert.equal(configurationChanges.Modified[0].key, "TestEnv");
assert.equal(configurationChanges.Deleted.length, 0);
});

it("Fail when an invalid import mode is provided", async()=> {
it("Fail when an invalid import mode is provided", async () => {
const options = {
data: fs.readFileSync(path.join("__dirname", "../tests/sources/kvset.json")).toString(),
format: ConfigurationFormat.Json,
profile: ConfigurationProfile.KvSet
};
const stringConfigurationSource = new StringConfigurationSettingsSource(options);

try {
await appConfigurationImporter.Import(stringConfigurationSource, 3, false, undefined, 9, false);
await appConfigurationImporter.getConfigurationChanges(stringConfigurationSource, false, 9 as unknown as ImportMode);
}
catch (error) {
assert.isTrue(error instanceof ArgumentError);
Expand Down
Loading
Loading