diff --git a/libraries/azure-app-configuration-importer-file-source/package.json b/libraries/azure-app-configuration-importer-file-source/package.json index e338ede..c9daac6 100644 --- a/libraries/azure-app-configuration-importer-file-source/package.json +++ b/libraries/azure-app-configuration-importer-file-source/package.json @@ -2,7 +2,7 @@ "name": "@azure/app-configuration-importer-file-source", "author": "Microsoft Corporation", "description": "A client library for importing/exporting key-values between configuration file sources and Azure App Configuration service", - "version": "1.1.3-preview", + "version": "2.0.0-preview", "sdk-type": "client", "keywords": [ "node", @@ -33,7 +33,7 @@ }, "dependencies": { "@azure/app-configuration": "^1.9.0", - "@azure/app-configuration-importer": "1.1.3-preview" + "@azure/app-configuration-importer": "2.0.0-preview" }, "devDependencies": { "@microsoft/api-extractor": "^7.22.2", diff --git a/libraries/azure-app-configuration-importer/package.json b/libraries/azure-app-configuration-importer/package.json index 727fbb8..a9cafd0 100644 --- a/libraries/azure-app-configuration-importer/package.json +++ b/libraries/azure-app-configuration-importer/package.json @@ -2,7 +2,7 @@ "name": "@azure/app-configuration-importer", "author": "Microsoft Corporation", "description": "A client library for importing/exporting key-values between configuration sources and Azure App Configuration service", - "version": "1.1.3-preview", + "version": "2.0.0-preview", "sdk-type": "client", "keywords": [ "node", diff --git a/libraries/azure-app-configuration-importer/src/appConfigurationImporter.ts b/libraries/azure-app-configuration-importer/src/appConfigurationImporter.ts index 56c7fc4..abd7bd3 100644 --- a/libraries/azure-app-configuration-importer/src/appConfigurationImporter.ts +++ b/libraries/azure-app-configuration-importer/src/appConfigurationImporter.ts @@ -8,10 +8,11 @@ import { FeatureFlagValue, SecretReferenceValue } from "@azure/app-configuration"; import { ConfigurationSettingsSource } from "./settingsImport/configurationSettingsSource"; +import { ConfigurationChangesSource } from "./settingsImport/configurationChangesSource"; 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"; @@ -43,27 +44,119 @@ export class AppConfigurationImporter { * @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 Promise */ public async Import( configSettingsSource: ConfigurationSettingsSource, timeout: number, - strict = false, progressCallback?: (progress: ImportProgress) => unknown, - importMode?: ImportMode, - dryRun?: boolean - ) { - if (importMode == undefined) { - importMode = ImportMode.IgnoreMatch; - } - if (dryRun == undefined) { - dryRun = false; + strict?: boolean, + importMode?: ImportMode + ): Promise; + + /** + * Import pre-calculated configuration changes. + * Use when changes were previously obtained via GetConfigurationChanges(). + * + * Example usage: + * ```ts + * const changes = await importer.GetConfigurationChanges(source); + * // Then call Import: + * await importer.Import(changes, 60); + * ``` + * @param configurationChanges - Pre-calculated changes object. + * @param timeout - Seconds of entire import progress timeout. + * @param progressCallback - Callback to report progress of import. + */ + public async Import( + configurationChangesSource: ConfigurationChangesSource, + timeout: number, + progressCallback?: (progress: ImportProgress) => unknown + ): Promise; + + public async Import( + configurationSettingsSource: ConfigurationSettingsSource, + timeout: number, + progressCallback?: ((progress: ImportProgress) => unknown), + strict = false, + importMode: ImportMode = ImportMode.IgnoreMatch + ): Promise { + // Generate correlationRequestId for operations in the same activity + const customCorrelationRequestId: string = uuidv4(); + const customHeadersOption: OperationOptions = { + requestOptions: { + customHeaders: { + [Constants.CorrelationRequestIdHeader]: customCorrelationRequestId + } + } + }; + + this.validateImportMode(importMode); + + if (configurationSettingsSource instanceof ConfigurationChangesSource) { + // When using ConfigurationChanges, strict and importMode parameters are not applicable + if (strict !== false || importMode !== ImportMode.IgnoreMatch) { + throw new ArgumentError("Parameters 'strict' and 'importMode' are not applicable when importing pre-calculated changes."); + } } + + const configurationChanges = await this.GetConfigurationChanges(configurationSettingsSource, strict, importMode, customHeadersOption); + return await this.applyUpdatesToServer([...configurationChanges.ToAdd, ...configurationChanges.ToModify], configurationChanges.ToDelete, timeout, customHeadersOption, progressCallback); + } + + /** + * Get configuration changes between source settings and existing settings in Azure App Configuration service without applying any changes + * + * 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( + configSettingsSource: ConfigurationSettingsSource, + strict = false, + importMode = ImportMode.IgnoreMatch, + customHeadersOption?: OperationOptions + ): Promise { this.validateImportMode(importMode); - const configSettings = await configSettingsSource.GetConfigurationSettings(); + // Generate correlationRequestId for operations in the same activity + if (!customHeadersOption) { + const customCorrelationRequestId: string = uuidv4(); + customHeadersOption = { + requestOptions: { + customHeaders: { + [Constants.CorrelationRequestIdHeader]: customCorrelationRequestId + } + } + }; + } + + const configSettingsResult = await configSettingsSource.GetConfigurationSettings(); + // If the source returns ConfigurationChanges (e.g., ConfigurationChangesSource), + // return them directly without further processing since changes are already calculated + if (this.isConfigurationChanges(configSettingsResult)) { + return configSettingsResult as ConfigurationChanges; + } + + const configSettings = configSettingsResult as Array>; const configurationSettingToDelete: ConfigurationSetting[] = []; + const configurationSettingToModify: SetConfigurationSettingParam[] = []; + const configurationSettingToAdd: SetConfigurationSettingParam[] = []; const srcKeyLabelLookUp: KeyLabelLookup = {}; configSettings.forEach((config: SetConfigurationSettingParam) => { @@ -73,63 +166,39 @@ export class AppConfigurationImporter { 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 - } + configurationSettingToAdd.push(...configSettings); + + 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); } - }; - if (strict || importMode == ImportMode.IgnoreMatch) { - for await (const existing of this.configurationClient.listConfigurationSettings({...configSettingsSource.FilterOptions, ...customHeadersOption})) { + const incoming = configSettings.find(configSetting => configSetting.key == existing.key && configSetting.label === existing.label); + + if (incoming) { - const isKeyLabelPresent: boolean = srcKeyLabelLookUp[existing.key] && srcKeyLabelLookUp[existing.key][existing.label || ""]; - - if (strict && !isKeyLabelPresent) { - configurationSettingToDelete.push(existing); + if (!isConfigSettingEqual(incoming, existing)) { + configurationSettingToModify.push(incoming); + // Remove from add list since it's a modification, not an addition + configurationSettingToAdd.splice(configurationSettingToAdd.indexOf(incoming), 1); } - - 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 + configurationSettingToAdd.splice(configurationSettingToAdd.indexOf(incoming), 1); } } } - - if (dryRun) { - this.printUpdatesToConsole(configSettings, configurationSettingToDelete); - } - else { - await this.applyUpdatesToServer(configSettings, configurationSettingToDelete, timeout, customHeadersOption, progressCallback); - } - } - - private printUpdatesToConsole( - settingsToAdd: SetConfigurationSettingParam[], - settingsToDelete: ConfigurationSetting[] - ): 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 { + ToAdd: configurationSettingToAdd, + ToModify: configurationSettingToModify, + ToDelete: configurationSettingToDelete + }; } private async applyUpdatesToServer( - settingsToAdd: SetConfigurationSettingParam[], + settingsToPut: SetConfigurationSettingParam[], settingsToDelete: ConfigurationSetting[], timeout: number, options: OperationOptions, @@ -142,7 +211,7 @@ export class AppConfigurationImporter { const deleteTimeConsumed = (endTime - startTime) / 1000; timeout -= deleteTimeConsumed; - const importTaskManager = this.newAdaptiveTaskManager((setting) => this.configurationClient.setConfigurationSetting(setting, options), settingsToAdd); + const importTaskManager = this.newAdaptiveTaskManager((setting) => this.configurationClient.setConfigurationSetting(setting, options), settingsToPut); await this.executeTasksWithTimeout(importTaskManager, timeout, progressCallback); } @@ -177,4 +246,16 @@ export class AppConfigurationImporter { throw new ArgumentError("Only options supported for Import Mode are 'All' and 'Ignore-Match'."); } } + + /** + * Type guard to detect a ConfigurationChanges object. + * @internal + */ + private isConfigurationChanges(obj: unknown): obj is ConfigurationChanges { + if (obj === null || typeof obj !== "object") { + return false; + } + const configChanges = obj as Partial; + return Array.isArray(configChanges.ToAdd) && Array.isArray(configChanges.ToModify) && Array.isArray(configChanges.ToDelete); + } } diff --git a/libraries/azure-app-configuration-importer/src/index.ts b/libraries/azure-app-configuration-importer/src/index.ts index f90bec9..a54e37f 100644 --- a/libraries/azure-app-configuration-importer/src/index.ts +++ b/libraries/azure-app-configuration-importer/src/index.ts @@ -9,8 +9,9 @@ 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 { ConfigurationChangesSource } from "./settingsImport/configurationChangesSource"; export { IterableConfigurationSettingsSource } from "./settingsImport/iterableConfigurationSettingsSource"; export { ReadableStreamConfigurationSettingsSource } from "./settingsImport/readableStreamConfigurationSettingsSource"; \ No newline at end of file diff --git a/libraries/azure-app-configuration-importer/src/models.ts b/libraries/azure-app-configuration-importer/src/models.ts index 7abb4a0..731a6d5 100644 --- a/libraries/azure-app-configuration-importer/src/models.ts +++ b/libraries/azure-app-configuration-importer/src/models.ts @@ -1,6 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { + SecretReferenceValue, + ConfigurationSetting, + SetConfigurationSettingParam, + FeatureFlagValue +} from "@azure/app-configuration"; + /** * @internal */ @@ -42,4 +49,10 @@ export interface KeyLabelLookup { [key: string]: { [label: string] : boolean } +} + +export interface ConfigurationChanges { + ToDelete: ConfigurationSetting[]; + ToModify: SetConfigurationSettingParam[]; + ToAdd: SetConfigurationSettingParam[]; } \ No newline at end of file diff --git a/libraries/azure-app-configuration-importer/src/settingsImport/configurationChangesSource.ts b/libraries/azure-app-configuration-importer/src/settingsImport/configurationChangesSource.ts new file mode 100644 index 0000000..f5c03b5 --- /dev/null +++ b/libraries/azure-app-configuration-importer/src/settingsImport/configurationChangesSource.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ConfigurationSettingsSource } from "./configurationSettingsSource"; +import { ConfigurationChanges } from "../models"; +import { ListConfigurationSettingsOptions } from "@azure/app-configuration"; +import { ArgumentError } from "../errors"; + +export class ConfigurationChangesSource implements ConfigurationSettingsSource { + private readonly configurationChanges: ConfigurationChanges; + + constructor(configurationChanges: ConfigurationChanges, filterOptions?: ListConfigurationSettingsOptions) { + if (filterOptions && Object.keys(filterOptions).length > 0) { + throw new ArgumentError("FilterOptions are not supported for ConfigurationChangesSource."); + } + this.configurationChanges = configurationChanges; + } + + /** + * @inheritdoc + */ + public async GetConfigurationSettings(): Promise { + return this.configurationChanges; + } +} \ No newline at end of file diff --git a/libraries/azure-app-configuration-importer/src/settingsImport/configurationSettingsSource.ts b/libraries/azure-app-configuration-importer/src/settingsImport/configurationSettingsSource.ts index 4e921a9..51a1822 100644 --- a/libraries/azure-app-configuration-importer/src/settingsImport/configurationSettingsSource.ts +++ b/libraries/azure-app-configuration-importer/src/settingsImport/configurationSettingsSource.ts @@ -7,6 +7,7 @@ import { SecretReferenceValue, SetConfigurationSettingParam } from "@azure/app-configuration"; +import { ConfigurationChanges } from "../models"; /** * Interface of all ConfigurationSettingsSource @@ -15,14 +16,14 @@ export interface ConfigurationSettingsSource { /** * Get ConfigurationSettings collection from source. * - * @returns Collection of ConfigurationSettings + * @returns Collection of ConfigurationSettings or ConfigurationChanges */ - GetConfigurationSettings(): Promise>>; + GetConfigurationSettings(): Promise> | ConfigurationChanges>; /** * Get label and prefix filter * * @returns label and prefix */ - FilterOptions: ListConfigurationSettingsOptions; + FilterOptions?: ListConfigurationSettingsOptions; } diff --git a/libraries/azure-app-configuration-importer/tests/appConfigurationImporter.spec.ts b/libraries/azure-app-configuration-importer/tests/appConfigurationImporter.spec.ts index 7491a9c..3b73edd 100644 --- a/libraries/azure-app-configuration-importer/tests/appConfigurationImporter.spec.ts +++ b/libraries/azure-app-configuration-importer/tests/appConfigurationImporter.spec.ts @@ -6,6 +6,7 @@ import * as path from "path"; import * as fs from "fs"; import { ConfigurationFormat, ConfigurationProfile, ImportMode } from "../src/enums"; import { StringConfigurationSettingsSource } from "../src/settingsImport/stringConfigurationSettingsSource"; +import { ConfigurationChangesSource } from "../src/settingsImport/configurationChangesSource"; import { AppConfigurationClient, ConfigurationSetting, @@ -50,7 +51,7 @@ describe("Call Import API to import configuration file to AppConfiguration", () finished = importProgress.successCount; total = importProgress.importCount; }; - await appConfigurationImporter.Import(stringConfigurationSource, 3, false, reportImportProgress); + await appConfigurationImporter.Import(stringConfigurationSource, 3, reportImportProgress, false); assert.equal(finished, 3); assert.equal(total, 3); }); @@ -66,7 +67,7 @@ describe("Call Import API to import configuration file to AppConfiguration", () format: ConfigurationFormat.Json }; const stringConfigurationSource = new StringConfigurationSettingsSource(options); - const importPromise = appConfigurationImporter.Import(stringConfigurationSource, 1, false); + const importPromise = appConfigurationImporter.Import(stringConfigurationSource, 1, undefined, false); importPromise.catch((e) => { expect(e.message).to.eq("server error"); }); @@ -101,7 +102,7 @@ describe("Call Import API to import configuration file to AppConfiguration", () }; const stringConfigurationSource = new StringConfigurationSettingsSource(options); try { - await appConfigurationImporter.Import(stringConfigurationSource, 1, false); + await appConfigurationImporter.Import(stringConfigurationSource, 1, undefined, false); } catch (error) { assert.isTrue(error instanceof OperationTimeoutError); @@ -144,7 +145,7 @@ describe("Call Import API to import configuration file to AppConfiguration", () total = importProgress.importCount; }; const stringConfigurationSource = new StringConfigurationSettingsSource(options); - await appConfigurationImporter.Import(stringConfigurationSource, 10, false, reportImportProgress); + await appConfigurationImporter.Import(stringConfigurationSource, 10, reportImportProgress, false); assert.equal(finished, 3); assert.equal(total, 3); }); @@ -159,7 +160,7 @@ describe("Call Import API to import configuration file to AppConfiguration", () format: ConfigurationFormat.Json }; const stringConfigurationSource = new StringConfigurationSettingsSource(options); - await appConfigurationImporter.Import(stringConfigurationSource, 10, false); + await appConfigurationImporter.Import(stringConfigurationSource, 10, undefined, false); }); it("Try import an empty file, no error", async () => { @@ -172,7 +173,7 @@ describe("Call Import API to import configuration file to AppConfiguration", () format: ConfigurationFormat.Json }; const stringConfigurationSource = new StringConfigurationSettingsSource(options); - await appConfigurationImporter.Import(stringConfigurationSource, 10, false); + await appConfigurationImporter.Import(stringConfigurationSource, 10, undefined, false); }); it("Succeed to import simple key value file in strict mode", async () => { @@ -226,27 +227,108 @@ describe("Call Import API to import configuration file to AppConfiguration", () finished = importProgress.successCount; total = importProgress.importCount; }; - await appConfigurationImporter.Import(stringConfigurationSource, 5, true, reportImportProgress); + await appConfigurationImporter.Import(stringConfigurationSource, 5, reportImportProgress, true); assert.equal(finished, 3); 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; + it("Succeed to import configuration changes with Import API", async () => { + const headerLike = sinon.createStubInstance(MockUpHttpHeaderLike); + const resourceLike = sinon.createStubInstance(MockupResourceLike); + const mockedResponse: SetConfigurationSettingResponse = { + key: "fakeKey", + isReadOnly: false, + _response: { + status: 200, + request: resourceLike, + headers: headerLike, + parsedHeaders: {}, + bodyAsText: "fakeBody" + } + }; + const mockedDeleteResponse: DeleteConfigurationSettingResponse = { + _response: { + status: 200, + request: resourceLike, + headers: headerLike, + parsedHeaders: {}, + bodyAsText: "fakeBody" + }, + statusCode: 200 + }; + + const AppConfigurationClientStub = sinon.createStubInstance(AppConfigurationClient); + AppConfigurationClientStub.setConfigurationSetting.resolves(mockedResponse); + AppConfigurationClientStub.deleteConfigurationSetting.resolves(mockedDeleteResponse); + AppConfigurationClientStub.listConfigurationSettings.returns(listConfigurationSettings()); + const appConfigurationImporter = new AppConfigurationImporter(AppConfigurationClientStub); + + 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); + + // Call GetConfigurationChanges to get the changes we would import + const configurationChanges = await appConfigurationImporter.GetConfigurationChanges( + stringConfigurationSource, + false, + ImportMode.All + ); + + assert.equal(configurationChanges.ToAdd.length, 2); + assert.equal(configurationChanges.ToModify.length, 1); + assert.equal(configurationChanges.ToDelete.length, 0); + assert.equal(configurationChanges.ToModify[0].key, "app:Settings:FontColor"); + + let finished = 0; + let total = 0; + const reportImportProgress = (importProgress: ImportProgress) => { + finished = importProgress.successCount; + total = importProgress.importCount; + }; + + // Use Import API with pre-calculated changes + const changesSourceForTest = new ConfigurationChangesSource(configurationChanges); + await appConfigurationImporter.Import(changesSourceForTest, 5, reportImportProgress); + assert.equal(finished, 3); + assert.equal(total, 3); + }); + + it("Fail to import ConfigurationChangesSource when both strict and importMode parameters are provided", async () => { + const AppConfigurationClientStub = sinon.createStubInstance(AppConfigurationClient); + const appConfigurationImporter = new AppConfigurationImporter(AppConfigurationClientStub); + + const configurationChanges = { + ToAdd: [{ key: "testKey", value: "testValue" }], + ToModify: [], + ToDelete: [] + }; + + const changesSource = new ConfigurationChangesSource(configurationChanges); + + try { + await appConfigurationImporter.Import(changesSource, 5, undefined, true, ImportMode.All); + } + catch (error) { + expect(error).to.be.instanceOf(ArgumentError); + expect((error as ArgumentError).message).to.contain("Parameters 'strict' and 'importMode' are not applicable when importing pre-calculated changes"); + } + }); + + 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, @@ -254,26 +336,15 @@ describe("Call Import API to import configuration file to AppConfiguration", () 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.ToAdd.length, 2); + assert.equal(configurationChanges.ToModify.length, 1); + assert.equal(configurationChanges.ToDelete.length, 0); + assert.equal(configurationChanges.ToModify[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, @@ -281,81 +352,55 @@ describe("Call Import API to import configuration file to AppConfiguration", () 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.ToAdd.length, 0); + assert.equal(configurationChanges.ToModify.length, 1); + assert.equal(configurationChanges.ToModify[0].key, "app:Settings:FontColor"); + assert.equal(configurationChanges.ToDelete.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); - - //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); + const configurationChanges = await appConfigurationImporter.GetConfigurationChanges(stringConfigurationSource, false, ImportMode.All); + // All key-values in App Configuration will be updated + assert.equal(configurationChanges.ToAdd.length, 2); + assert.equal(configurationChanges.ToModify.length, 1); + assert.equal(configurationChanges.ToModify[0].key, "TestEnv"); + assert.equal(configurationChanges.ToDelete.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 ToModify + assert.equal(configurationChanges.ToAdd.length, 0); + assert.equal(configurationChanges.ToModify.length, 1); + assert.equal(configurationChanges.ToModify[0].key, "TestEnv"); + assert.equal(configurationChanges.ToDelete.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); diff --git a/libraries/azure-app-configuration-importer/tests/iterableConfigurationSettingsSource.spec.ts b/libraries/azure-app-configuration-importer/tests/iterableConfigurationSettingsSource.spec.ts index dc1f9e1..51a5bf8 100644 --- a/libraries/azure-app-configuration-importer/tests/iterableConfigurationSettingsSource.spec.ts +++ b/libraries/azure-app-configuration-importer/tests/iterableConfigurationSettingsSource.spec.ts @@ -63,7 +63,7 @@ describe("Iterator configuration source test", () => { finished = importProgress.successCount; total = importProgress.importCount; }; - await appConfigurationImporter.Import(iteratorConfigurationSettingsSource, 3, false, reportImportProgress); + await appConfigurationImporter.Import(iteratorConfigurationSettingsSource, 3, reportImportProgress, false); assert.equal(finished, 6); assert.equal(total, 6); }); @@ -100,7 +100,7 @@ describe("Iterator configuration source test", () => { total = importProgress.importCount; }; - await appConfigurationImporter.Import(iteratorConfigurationSettingsSource, 3, false, reportImportProgress, ImportMode.All); + await appConfigurationImporter.Import(iteratorConfigurationSettingsSource, 3, reportImportProgress, false, ImportMode.All); assert.equal(finished, 6); assert.equal(total, 6); }); @@ -137,7 +137,7 @@ describe("Iterator configuration source test", () => { total = importProgress.importCount; }; - await appConfigurationImporter.Import(iteratorConfigurationSettingsSource, 3, false, reportImportProgress, ImportMode.IgnoreMatch); // Ignore-Match is default import mode + await appConfigurationImporter.Import(iteratorConfigurationSettingsSource, 3, reportImportProgress, false, ImportMode.IgnoreMatch); // Ignore-Match is default import mode assert.equal(finished, 0); assert.equal(total, 0); }); diff --git a/libraries/azure-app-configuration-importer/tests/kvSetConfigurationSettingsConverter.spec.ts b/libraries/azure-app-configuration-importer/tests/kvSetConfigurationSettingsConverter.spec.ts index 944d109..a84d602 100644 --- a/libraries/azure-app-configuration-importer/tests/kvSetConfigurationSettingsConverter.spec.ts +++ b/libraries/azure-app-configuration-importer/tests/kvSetConfigurationSettingsConverter.spec.ts @@ -190,7 +190,6 @@ describe("Parse kvset format file", () => { }); it("Delete key-values present in the store but not available in the config file", async()=>{ - const spy = sinon.spy(console,"log"); const AppConfigurationClientStub = sinon.createStubInstance(AppConfigurationClient); AppConfigurationClientStub.listConfigurationSettings.returns(listConfigurationSettings()); const appConfigurationImporter = new AppConfigurationImporter(AppConfigurationClientStub); @@ -201,18 +200,15 @@ describe("Parse kvset format file", () => { }; const stringConfigurationSource = new StringConfigurationSettingsSource(options); - await appConfigurationImporter.Import(stringConfigurationSource, 3, true, undefined, ImportMode.All, true); - + const configurationChanges = await appConfigurationImporter.GetConfigurationChanges(stringConfigurationSource, true, ImportMode.All); + // The keys present in the store and not in the configuration file are deleted if strict is set to true - assert.equal(spy.getCall(0).args[0], "The following settings will be removed from App Configuration:"); - assert.equal(spy.getCall(1).args[0], "{\"key\":\"app:Settings:FontSize\",\"label\":\"Dev\"}"); - assert.equal(spy.getCall(2).args[0], "{\"key\":\"app:Settings:BackgroundColor\",\"label\":\"Dev\",\"tags\":{}}"); - assert.equal(spy.getCall(3).args[0], "{\"key\":\"app:Settings:FontColor\",\"label\":\"Dev\",\"tags\":{\"tag1\":\"value1\",\"tag2\":\"value2\"}}"); - spy.restore(); + const DeleteKeys = configurationChanges.ToDelete.map(d => d.key); + assert.equal(configurationChanges.ToDelete.length, 3); + assert.includeMembers(DeleteKeys, ["app:Settings:FontSize", "app:Settings:BackgroundColor", "app:Settings:FontColor"]); }); it("Delete key-values present in the store but not available in the config file, with similar key but different label", async()=>{ - const spy = sinon.spy(console,"log"); const AppConfigurationClientStub = sinon.createStubInstance(AppConfigurationClient); AppConfigurationClientStub.listConfigurationSettings.returns(listConfigurationSettings()); const appConfigurationImporter = new AppConfigurationImporter(AppConfigurationClientStub); @@ -223,15 +219,17 @@ describe("Parse kvset format file", () => { }; const stringConfigurationSource = new StringConfigurationSettingsSource(options); - await appConfigurationImporter.Import(stringConfigurationSource, 3, true, undefined, ImportMode.All, true); - + const configurationChanges = await appConfigurationImporter.GetConfigurationChanges(stringConfigurationSource, true, ImportMode.All); + // The keys present in the store and not in the configuration file are deleted if strict is set to true - assert.equal(spy.getCall(0).args[0], "The following settings will be removed from App Configuration:"); - assert.equal(spy.getCall(1).args[0], "{\"key\":\"app:Settings:FontSize\",\"label\":\"Dev\"}"); - assert.equal(spy.getCall(2).args[0], "{\"key\":\"app:Settings:BackgroundColor\",\"label\":\"Dev\",\"tags\":{}}"); - assert.equal(spy.getCall(3).args[0], "{\"key\":\"app:Settings:FontColor\",\"label\":\"Dev\",\"tags\":{\"tag1\":\"value1\",\"tag2\":\"value2\"}}"); - assert.equal(spy.getCall(4).args[0], "{\"key\":\".appconfig.featureflag/Test\",\"label\":\"dev\",\"contentType\":\"application/vnd.microsoft.appconfig.ff+json;charset=utf-8\",\"tags\":{}}"); - assert.equal(spy.getCall(5).args[0], "{\"key\":\"Database:ConnectionString\",\"label\":\"test\",\"contentType\":\"application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8\"}"); - spy.restore(); + const DeleteKeys = configurationChanges.ToDelete.map(d => `key: ${d.key}, label: ${d.label}`); + assert.equal(configurationChanges.ToDelete.length, 5); + assert.includeMembers(DeleteKeys, [ + "key: app:Settings:FontSize, label: Dev", + "key: app:Settings:BackgroundColor, label: Dev", + "key: app:Settings:FontColor, label: Dev", + "key: .appconfig.featureflag/Test, label: dev", + "key: Database:ConnectionString, label: test" + ]); }); }); diff --git a/libraries/azure-app-configuration-importer/tests/readableStreamConfigurationSettingsSource.spec.ts b/libraries/azure-app-configuration-importer/tests/readableStreamConfigurationSettingsSource.spec.ts index 6eabceb..8dc0f93 100644 --- a/libraries/azure-app-configuration-importer/tests/readableStreamConfigurationSettingsSource.spec.ts +++ b/libraries/azure-app-configuration-importer/tests/readableStreamConfigurationSettingsSource.spec.ts @@ -161,7 +161,7 @@ describe("Readable stream configuration settings source tests", () => { finished = importProgress.successCount; total = importProgress.importCount; }; - await appConfigurationImporter.Import(readableConfigurationSettingsSource, 3, false, reportImportProgress); + await appConfigurationImporter.Import(readableConfigurationSettingsSource, 3, reportImportProgress, false); assert.equal(finished, 3); assert.equal(total, 3); });