There are three kinds of plugins:
- File Loaders - For reading local config files
- Remote Resolvers - For reading remote data sources
- Translators - For transforming raw data
File loaders are plugins that allow Dynamic Config to read local configuration files.
They are defined by this interface:
interface IFileLoader {
type: string
load(filePath: string): Promise<object>
}
Here, type
is the file extension handled by this loader and load
is the function to load the file. The load
function is expected to return a promise of the JavaScript Object loaded from the file.
The JavaScript loader is simple. Let's take a look at it as an example.
const jsLoader: IFileLoader = {
type: 'js',
async load(filePath: string): Promise<object> {
const configObj = require(filePath)
if (typeof configObj.default === 'object') {
return configObj.default
} else {
return configObj
}
},
}
By the time a loader is called with a filePath
the path is gauranteed to exist. The filePath
is absolute.
Loaders are given priority in the order in which they are added. Meaning the most recently added loader has the highest priority. With the default settings this order is json, yaml, js then ts. Therefore, TypeScript files have the highest priority. If there is both a default.json
file and a default.ts
file the values from the default.ts
file will have presidence.
Remote resolvers are plugins that know how to read data from data sources outside the filesystem.
They are defined by this interface:
interface IRemoteResolver {
type: 'remote' | 'secret'
name: string
init(configInstance: IConfigStore, remoteOptions?: IRemoteOptions): Promise<any>
get<T>(key: string): Promise<T>
watch<T>(key: string, cb: (val: T) => void): void
}
The type parameter can be set to either remote
or secret
. The only difference is that remote
allows for default values.
The name for this remote. This is used to lookup config placeholders, the _source
property of a placeholder.
The init method is called and resolved before any request to conifg().get
can be completed. The init method returns a Promise. The resolved value of this Promise is deeply merged with the local config. This is where you load remote configuration that should be available on application startup.
The init method receives an instance of the IConfigStore
object and any optional parameters that were defined with out config options (the remoteOptions
piece of our config options). The IConfigStore
instance is a simple object store that represents the config as it exists at the moment in time that this resolver is being resolved. Remote resolvers are initialized sequentially in the order in which they are registered, meaning a remote resolver has access to all of the config values from remotes that were previously initialized.
The IConfigStore
interface is as follows:
interface IConfigStore {
get<T = any>(key: string): T | null
getAll(...args: Array<string>): Array<any>
getWithDefault<T = any>(key: string, defaultVal: T): T
}
This allows a resolver's initialization to rely on configuration loaded through local config files or through a previously loaded remote.
As a reminder, remoteOptions
could be set in config-settings.json
as such:
{
"remoteOptions": {
"consul": {
"consulAddress": "http://localhost:8500",
"consulDc": "dc1",
"consulKeys": "production-config",
"consulNamespace": "my-service-name",
}
}
}
When a resolver with the name 'consul'
is registered this object will be passed to the init method. Therefore, the remoteOptions
parameter is of the form:
interface IRemoteOptions {
[resolverName: string]: any
}
This is easy, given a string key return a value for it. This method is called when a value in the config needs to be resolved remotely. Usually this will be because of a config placeholder. Once this method resolves, the return value will be cached in the config object and this method will not be called for that same key again.
This alerts the remote that the user is watching a value. If there is machinery to set up to support this do it here. You get the key the user is watching and a callback to use when the value changes.
If your remote doesn't support watching just supply an empty function.
Translators are essentially mapping functions that can be used to transform raw values before they are added to the resolved config.
When data is loaded from a local file or remote source it is parsed, usually JSON.parse
, and then added to the resolved config object that you request values from. Sometimes, particularly when dealing with remote sources, the data coming in may not be exactly the shape you want, or it may be somewhat unreliable. Translators allow you to rewrite this data before it is added to the resolved config.
As a concrete example of this we will look at environment variables. A config placeholder, as we've seen earlier, is an object that looks something like this:
{
"host": {
"_source": "env",
"_key": "HOSTNAME"
}
}
However, in your config, you will more often want to write something like this:
{
"destination": "http://${HOSTNAME}:9000"
}
The envTranslator
bundled with dynamic config will look at this and replace ${HOSTNAME}
with the environment variable HOSTNAME
before inserting the value into the resolved config object.
When using the envTranslator
you can also provide an inline default value for when the environment variable is missing. This is done with the double pipe ||
operator.
{
"destination": "http://${HOSTNAME||localhost}:9000"
}
In this case localhost
will be used if HOSTNAME
is not found in the current environment.
They are defined by this interface:
interface IConfigTranslator {
path?: string | Array<string>
translate(configValue: any): any
}
The path in the config to apply this translator to. By default the translator will be applied to every key in the config. This limits the paths to apply the translator to. Paths can be nested, such as database.password
.
The function to translate the value. A simple mapping function, though it should know how to ignore objects it doesn't apply to.
Once you have created a plugin you need to register it with the DynamicConfig
instance. To do this you need to pass them in to the config
function the first time you call it.
import { DynamicConfig, config, jsonLoader, consulResolver, envTranslator } from '@creditkarma/dynamic-config'
const configInstance: DynamicConfig = config({
loaders: [ jsonLoader ],
resolvers: [ consulResolver() ],
translators: [ envTranslator ]
})
Note: Here consulResolver
is a function that returns IRemoteResolver
because there is state that needs to be initialized for this resolver.