Config API
Last modified by Manuel Leduc on 2024/03/12 09:40
Description
API Requirements
- Able to run in the browser
- Compatible with typescript
- Being able to load a value from a hierarchical set of sources (e.g., a properties file, an XObject...)
- Being able to re-load values at runtime (e.g., an XObject property is updated)
- Preferable: efficient caching mechanism
- Preferable: type-safe Typescript API
Existing Libraries
As of now, all the libraries I could find are designed to be run in a no environment (i.e., outside a browser).
List of such libraries:
In conclusion, the way we handle configurations is very specific to the way wikis work, and no existing config library fits our needs.
Therefore, we are going to need to implement our own config library.
Integration with the backend
Since configuration needs to be re-computed at runtime, and easy design is actually to have a single source of truth in the browser.
Assumptions:
- the schema is static
- the values can change
- a value can be missing at a given level, and in this case is resolved at the parent level
- each value is in charge of defining when it must be loaded first (i.e., at startup or lazily), if it can change over-time, and a when the value can be invalidated.
- no need for a notion of right, if a value is returned client-side and shouldn't be visible to the current user, it's a security issue on the server
- the API is read-only, the user has now way to change the configuration value through this API, and must instead use APIs to edit the underlying source to change a config value
- as value can be fetched on-demand from the server (e.g., from a rest endpoint), any configuration result must be wrapped in a Promise, and should be expected to fail for external reasons (e.g., punctual networks issue)
Snippet
API
/**
* Defines a common set of methods to access for configuration values.
*
* @since 0.6
*/
export interface ConfigurationSource {
/**
* Get a configuration value for the given key. Fallbacks to the default value if no value is found.
*
* @param key the configuration key to access
* @param defaultValue a default value if no value is found for the request key
* @return a promise with the configuration value
* @throws ConfigurationException in case of issue when accessing the configuration
*/
getProperty<T>(key: string, defaultValue?: T): Promise<T | undefined>;
/**
* Returns `true` if the configuration source has the given key, `false` otherwise.
*
* @param key the configuration key to check the presence of
* @return a promise with `true` if the configuration source has the given key, `false` otherwise`
*/
hasProperty(key: string): Promise<boolean>
}
/**
* Exception thrown in case of configuration issue.
*
* @since 0.6
*/
export class ConfigurationException extends Error {
}
* Defines a common set of methods to access for configuration values.
*
* @since 0.6
*/
export interface ConfigurationSource {
/**
* Get a configuration value for the given key. Fallbacks to the default value if no value is found.
*
* @param key the configuration key to access
* @param defaultValue a default value if no value is found for the request key
* @return a promise with the configuration value
* @throws ConfigurationException in case of issue when accessing the configuration
*/
getProperty<T>(key: string, defaultValue?: T): Promise<T | undefined>;
/**
* Returns `true` if the configuration source has the given key, `false` otherwise.
*
* @param key the configuration key to check the presence of
* @return a promise with `true` if the configuration source has the given key, `false` otherwise`
*/
hasProperty(key: string): Promise<boolean>
}
/**
* Exception thrown in case of configuration issue.
*
* @since 0.6
*/
export class ConfigurationException extends Error {
}
Implementation
export class DomConfigurationSource implements ConfigurationSource {
private loadedConfiguration: Map<string, any> | undefined;
private loaded = false;
constructor(readonly elementId: string, readonly lazy: boolean = false) {
if (!this.lazy) {
this.loadConfiguration();
}
}
async getProperty<T>(key: string, defaultValue?: T | undefined): Promise<T | undefined> {
this.loadConfiguration();
let booleanPromise = await this.hasProperty(key);
if (!booleanPromise) {
return defaultValue;
}
return this.loadedConfiguration?.get(key)
}
async hasProperty(key: string): Promise<boolean> {
this.loadConfiguration();
return this.loadedConfiguration && this.loadedConfiguration.has(key) || false;
}
private loadConfiguration() {
if (this.loaded) {
// Skip if a loading attempt was already done.
return;
}
this.loaded = true;
const elementById = document.getElementById(this.elementId);
if (elementById != null) {
try {
this.loadedConfiguration = new Map(Object.entries(JSON.parse(elementById.innerText)));
} catch (e) {
throw new ConfigurationException(
`Failed to parse [${elementById.innerText}] for configuration [${this.elementId}]`, {cause: e})
}
} else {
throw new ConfigurationException(`Failed to find element [${this.elementId}]`)
}
}
}
export class ChainedConfigurationSource implements ConfigurationSource {
constructor(readonly chain: Array<ConfigurationSource>) {
}
async getProperty<T>(key: string, defaultValue?: T | undefined): Promise<T | undefined> {
for (let configuration of this.chain) {
const hasProperty = await configuration.hasProperty(key)
if (hasProperty) {
// We stop at the first level where a property is found.
return configuration.getProperty(key);
}
}
return Promise.resolve(defaultValue);
}
async hasProperty(key: string): Promise<boolean> {
for(let configuration of this.chain) {
const hasProperty = await configuration.hasProperty(key);
if(hasProperty) {
return true;
}
}
return false;
}
}
private loadedConfiguration: Map<string, any> | undefined;
private loaded = false;
constructor(readonly elementId: string, readonly lazy: boolean = false) {
if (!this.lazy) {
this.loadConfiguration();
}
}
async getProperty<T>(key: string, defaultValue?: T | undefined): Promise<T | undefined> {
this.loadConfiguration();
let booleanPromise = await this.hasProperty(key);
if (!booleanPromise) {
return defaultValue;
}
return this.loadedConfiguration?.get(key)
}
async hasProperty(key: string): Promise<boolean> {
this.loadConfiguration();
return this.loadedConfiguration && this.loadedConfiguration.has(key) || false;
}
private loadConfiguration() {
if (this.loaded) {
// Skip if a loading attempt was already done.
return;
}
this.loaded = true;
const elementById = document.getElementById(this.elementId);
if (elementById != null) {
try {
this.loadedConfiguration = new Map(Object.entries(JSON.parse(elementById.innerText)));
} catch (e) {
throw new ConfigurationException(
`Failed to parse [${elementById.innerText}] for configuration [${this.elementId}]`, {cause: e})
}
} else {
throw new ConfigurationException(`Failed to find element [${this.elementId}]`)
}
}
}
export class ChainedConfigurationSource implements ConfigurationSource {
constructor(readonly chain: Array<ConfigurationSource>) {
}
async getProperty<T>(key: string, defaultValue?: T | undefined): Promise<T | undefined> {
for (let configuration of this.chain) {
const hasProperty = await configuration.hasProperty(key)
if (hasProperty) {
// We stop at the first level where a property is found.
return configuration.getProperty(key);
}
}
return Promise.resolve(defaultValue);
}
async hasProperty(key: string): Promise<boolean> {
for(let configuration of this.chain) {
const hasProperty = await configuration.hasProperty(key);
if(hasProperty) {
return true;
}
}
return false;
}
}
Tests
import {expect, test} from "vitest";
import {DomConfigurationSource} from "./index";
test('config-api', async () => {
document.body.innerHTML = `
<div id="container-wrong-content">{</div>
<div id="container">{"a": 1}</div>
`;
expect(() => new DomConfigurationSource("doesnotexist")).toThrowError("Failed to find element [doesnotexist]")
expect(() => new DomConfigurationSource("container-wrong-content")).toThrowError(
"Failed to parse [{] for configuration [container-wrong-content]")
const domConfiguration = new DomConfigurationSource("container");
expect(await domConfiguration.hasProperty("a")).toBe(true)
expect(await domConfiguration.hasProperty("b")).toBe(false)
expect(await domConfiguration.getProperty("a", 42)).toBe(1)
expect(await domConfiguration.getProperty("b", 42)).toBe(42)
})
import {DomConfigurationSource} from "./index";
test('config-api', async () => {
document.body.innerHTML = `
<div id="container-wrong-content">{</div>
<div id="container">{"a": 1}</div>
`;
expect(() => new DomConfigurationSource("doesnotexist")).toThrowError("Failed to find element [doesnotexist]")
expect(() => new DomConfigurationSource("container-wrong-content")).toThrowError(
"Failed to parse [{] for configuration [container-wrong-content]")
const domConfiguration = new DomConfigurationSource("container");
expect(await domConfiguration.hasProperty("a")).toBe(true)
expect(await domConfiguration.hasProperty("b")).toBe(false)
expect(await domConfiguration.getProperty("a", 42)).toBe(1)
expect(await domConfiguration.getProperty("b", 42)).toBe(42)
})