Skip to content
Merged
27 changes: 26 additions & 1 deletion src/AzureAppConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,29 @@ export type AzureAppConfiguration = {
* @param thisArg - Optional. Value to use as `this` when executing callback.
*/
onRefresh(listener: () => any, thisArg?: any): Disposable;
} & ReadonlyMap<string, any>;
} & IGettable & IConfigurationObject;

interface IConfigurationObject {
/**
* Construct configuration object based on Map-styled data structure and hierarchical keys.
* @param options - The options to control the conversion behavior.
*/
constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record<string, any>;
}

export interface ConfigurationObjectConstructionOptions {
/**
* The separator to use when converting hierarchical keys to object properties.
* If separator is undefined, '.' will be used by default.

*/
separator?: string;
}

interface IGettable {
/**
* Get the value of a key-value from the Map-styled data structure.
* @param key - The key of the key-value to be retrieved.
*/
get<T>(key: string): T | undefined;
}
52 changes: 46 additions & 6 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions } from "@azure/app-configuration";
import { RestError } from "@azure/core-rest-pipeline";
import { AzureAppConfiguration } from "./AzureAppConfiguration";
import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration";
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions";
import { IKeyValueAdapter } from "./IKeyValueAdapter";
import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter";
Expand All @@ -15,7 +15,12 @@ import { CorrelationContextHeaderName } from "./requestTracing/constants";
import { createCorrelationContextHeader, requestTracingEnabled } from "./requestTracing/utils";
import { KeyFilter, LabelFilter, SettingSelector } from "./types";

export class AzureAppConfigurationImpl extends Map<string, any> implements AzureAppConfiguration {
export class AzureAppConfigurationImpl implements AzureAppConfiguration {
/**
* Hosting key-value pairs in the configuration store.
*/
#configMap: Map<string, any> = new Map<string, any>();

#adapters: IKeyValueAdapter[] = [];
/**
* Trim key prefixes sorted in descending order.
Expand All @@ -40,7 +45,6 @@ export class AzureAppConfigurationImpl extends Map<string, any> implements Azure
client: AppConfigurationClient,
options: AzureAppConfigurationOptions | undefined
) {
super();
this.#client = client;
this.#options = options;

Expand Down Expand Up @@ -87,6 +91,9 @@ export class AzureAppConfigurationImpl extends Map<string, any> implements Azure
this.#adapters.push(new JsonKeyValueAdapter());
}

get<T>(key: string): T | undefined {
return this.#configMap.get(key);
}

get #refreshEnabled(): boolean {
return !!this.#options?.refreshOptions?.enabled;
Expand Down Expand Up @@ -157,9 +164,9 @@ export class AzureAppConfigurationImpl extends Map<string, any> implements Azure
keyValues.push([key, value]);
}

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

Expand All @@ -168,11 +175,44 @@ export class AzureAppConfigurationImpl extends Map<string, any> implements Azure
*/
async load() {
await this.#loadSelectedAndWatchedKeyValues();

// Mark all settings have loaded at startup.
this.#isInitialLoadCompleted = true;
}

/**
* Construct hierarchical data object from map.
*/
constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record<string, any> {
const separator = options?.separator ?? ".";

// construct hierarchical data object from map
const data: Record<string, any> = {};
for (const [key, value] of this.#configMap) {
const segments = key.split(separator);
let current = data;
// construct hierarchical data object along the path
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
// undefined or empty string
if (!segment) {
throw new Error(`invalid key: ${key}`);
}
// create path if not exist
if (current[segment] === undefined) {
current[segment] = {};
}
// The path has been occupied by a non-object value, causing ambiguity.
if (typeof current[segment] !== "object") {
throw new Error(`The key '${segments.slice(0, i + 1).join(separator)}' is not a valid path.`);
}
current = current[segment];
}
// set value to the last segment
current[segments[segments.length - 1]] = value;
}
return data;
}

/**
* Refresh the configuration store.
*/
Expand Down
6 changes: 3 additions & 3 deletions test/json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("json", function () {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString);
expect(settings).not.undefined;
const logging = settings.get("json.settings.logging");
const logging = settings.get<any>("json.settings.logging");
expect(logging).not.undefined;
expect(logging.Test).not.undefined;
expect(logging.Test.Level).eq("Debug");
Expand All @@ -43,7 +43,7 @@ describe("json", function () {
}
});
expect(settings).not.undefined;
const resolvedSecret = settings.get("TestKey");
const resolvedSecret = settings.get<any>("TestKey");
expect(resolvedSecret).not.undefined;
expect(resolvedSecret.uri).undefined;
expect(typeof resolvedSecret).eq("string");
Expand Down Expand Up @@ -74,7 +74,7 @@ describe("json", function () {
const settings = await load(connectionString);
expect(settings).not.undefined;
expect(typeof settings.get("json.settings.object")).eq("object", "is object");
expect(Object.keys(settings.get("json.settings.object")).length).eq(0, "is empty object");
expect(Object.keys(settings.get<any>("json.settings.object")).length).eq(0, "is empty object");
expect(Array.isArray(settings.get("json.settings.array"))).eq(true, "is array");
expect(settings.get("json.settings.number")).eq(8, "is number");
expect(settings.get("json.settings.string")).eq("string", "is string");
Expand Down
106 changes: 95 additions & 11 deletions test/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,24 @@ const mockedKVs = [{
}, {
key: "KeyForEmptyValue",
value: "",
}].map(createMockedKeyValue);
}, {
key: "app2.settings",
value: JSON.stringify({ fontColor: "blue", fontSize: 20 }),
contentType: "application/json"
}, {
key: "app3.settings",
value: "placeholder"
}, {
key: "app3.settings.fontColor",
value: "yellow"
}, {
key: "app4.excludedFolders.0",
value: "node_modules"
}, {
key: "app4.excludedFolders.1",
value: "dist"
}
].map(createMockedKeyValue);

describe("load", function () {
this.timeout(10000);
Expand Down Expand Up @@ -85,11 +102,8 @@ describe("load", function () {
trimKeyPrefixes: ["app.settings."]
});
expect(settings).not.undefined;
expect(settings.has("fontColor")).eq(true);
expect(settings.get("fontColor")).eq("red");
expect(settings.has("fontSize")).eq(true);
expect(settings.get("fontSize")).eq("40");
expect(settings.has("TestKey")).eq(false);
});

it("should trim longest key prefix first", async () => {
Expand All @@ -102,20 +116,15 @@ describe("load", function () {
trimKeyPrefixes: ["app.", "app.settings.", "Test"]
});
expect(settings).not.undefined;
expect(settings.has("fontColor")).eq(true);
expect(settings.get("fontColor")).eq("red");
expect(settings.has("fontSize")).eq(true);
expect(settings.get("fontSize")).eq("40");
expect(settings.has("TestKey")).eq(false);
});

it("should support null/empty value", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString);
expect(settings).not.undefined;
expect(settings.has("KeyForNullValue")).eq(true);
expect(settings.get("KeyForNullValue")).eq(null);
expect(settings.has("KeyForEmptyValue")).eq(true);
expect(settings.get("KeyForEmptyValue")).eq("");
});

Expand Down Expand Up @@ -153,7 +162,6 @@ describe("load", function () {
}]
});
expect(settings).not.undefined;
expect(settings.has("TestKey")).eq(true);
expect(settings.get("TestKey")).eq("TestValueForProd");
});

Expand All @@ -172,8 +180,84 @@ describe("load", function () {
}]
});
expect(settings).not.undefined;
expect(settings.has("TestKey")).eq(true);
expect(settings.get("TestKey")).eq("TestValueForProd");
});

// access data property
it("should directly access data property", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
selectors: [{
keyFilter: "app.settings.*"
}]
});
expect(settings).not.undefined;
const data = settings.constructConfigurationObject();
expect(data).not.undefined;
expect(data.app.settings.fontColor).eq("red");
expect(data.app.settings.fontSize).eq("40");
});

it("should access property of JSON object content-type with data accessor", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
selectors: [{
keyFilter: "app2.*"
}]
});
expect(settings).not.undefined;
const data = settings.constructConfigurationObject();
expect(data).not.undefined;
expect(data.app2.settings.fontColor).eq("blue");
expect(data.app2.settings.fontSize).eq(20);
});

it("should not access property of JSON content-type object with get()", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
selectors: [{
keyFilter: "app2.*"
}]
});
expect(settings).not.undefined;
expect(settings.get("app2.settings")).not.undefined; // JSON object accessed as a whole
expect(settings.get("app2.settings.fontColor")).undefined;
expect(settings.get("app2.settings.fontSize")).undefined;
});

/**
* Edge case: Hierarchical key-value pairs with overlapped key prefix.
* key: "app3.settings" => value: "placeholder"
* key: "app3.settings.fontColor" => value: "yellow"
*
* get() will return "placeholder" for "app3.settings" and "yellow" for "app3.settings.fontColor", as expected.
* data.app3.settings will return "placeholder" as a whole JSON object, which is not guarenteed to be correct.
*/
it("Edge case: Hierarchical key-value pairs with overlapped key prefix.", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
selectors: [{
keyFilter: "app3.settings*"
}]
});
expect(settings).not.undefined;
expect(() => {
settings.constructConfigurationObject();
}).to.throw("The key 'app3.settings' is not a valid path.");
});

it("should construct configuration object with array", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
selectors: [{
keyFilter: "app4.*"
}]
});
expect(settings).not.undefined;
const data = settings.constructConfigurationObject();
expect(data).not.undefined;
// Both { '0': 'node_modules', '1': 'dist' } and ['node_modules', 'dist'] are valid.
expect(data.app4.excludedFolders[0]).eq("node_modules");
expect(data.app4.excludedFolders[1]).eq("dist");
});
});