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.
* Supported values: '.', ',', ';', '-', '_', '__', '/', ':'.
* If separator is undefined, '.' will be used by default.
*/
separator?: "." | "," | ";" | "-" | "_" | "__" | "/" | ":";
}

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;
}
61 changes: 55 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,53 @@ 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 ?? ".";
const validSeparators = [".", ",", ";", "-", "_", "__", "/", ":"];
if (!validSeparators.includes(separator)) {
throw new Error(`Invalid separator '${separator}'. Supported values: ${validSeparators.map(s => `'${s}'`).join(", ")}.`);
}

// 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(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The path '${segments.slice(0, i + 1).join(separator)}' has been occupied.`);
}
current = current[segment];
}

const lastSegment = segments[segments.length - 1];
if (current[lastSegment] !== undefined) {
throw new Error(`Ambiguity occurs when constructing configuration object from key '${key}', value '${value}'. The key should not be part of another key.`);
}
// set value to the last segment
current[lastSegment] = 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
177 changes: 166 additions & 11 deletions test/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ const mockedKVs = [{
}, {
key: "app.settings.fontSize",
value: "40",
}, {
key: "app/settings/fontColor",
value: "red",
}, {
key: "app/settings/fontSize",
value: "40",
}, {
key: "app%settings%fontColor",
value: "red",
}, {
key: "app%settings%fontSize",
value: "40",
}, {
key: "TestKey",
label: "Test",
Expand All @@ -28,7 +40,30 @@ 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"
}, {
key: "app5.settings.fontColor",
value: "yellow"
}, {
key: "app5.settings",
value: "placeholder"
}
].map(createMockedKeyValue);

describe("load", function () {
this.timeout(10000);
Expand Down Expand Up @@ -85,11 +120,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 +134,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 +180,6 @@ describe("load", function () {
}]
});
expect(settings).not.undefined;
expect(settings.has("TestKey")).eq(true);
expect(settings.get("TestKey")).eq("TestValueForProd");
});

Expand All @@ -172,8 +198,137 @@ 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 1: 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("Ambiguity occurs when constructing configuration object from key 'app3.settings.fontColor', value 'yellow'. The path 'app3.settings' has been occupied.");
});

/**
* Edge case: Hierarchical key-value pairs with overlapped key prefix.
* key: "app5.settings.fontColor" => value: "yellow"
* key: "app5.settings" => value: "placeholder"
*
* When ocnstructConfigurationObject() is called, it first constructs from key "app5.settings.fontColor" and then from key "app5.settings".
* An error will be thrown when constructing from key "app5.settings" because there is ambiguity between the two keys.
*/
it("Edge case 1: Hierarchical key-value pairs with overlapped key prefix.", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
selectors: [{
keyFilter: "app5.settings*"
}]
});
expect(settings).not.undefined;
expect(() => {
settings.constructConfigurationObject();
}).to.throw("Ambiguity occurs when constructing configuration object from key 'app5.settings', value 'placeholder'. The key should not be part of another key.");
});

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");
});

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

it("should throw error when construct configuration object with invalid separator", async () => {
const connectionString = createMockedConnectionString();
const settings = await load(connectionString, {
selectors: [{
keyFilter: "app%settings%*"
}]
});
expect(settings).not.undefined;

expect(() => {
// Below line will throw error because of type checking, i.e. Type '"%"' is not assignable to type '"/" | "." | "," | ";" | "-" | "_" | "__" | ":" | undefined'.ts(2322)
// Here force to turn if off for testing purpose, as JavaScript does not have type checking.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
settings.constructConfigurationObject({ separator: "%" });
}).to.throw("Invalid separator '%'. Supported values: '.', ',', ';', '-', '_', '__', '/', ':'.");
});
});