/ezyconfig

Primary LanguageTypeScript

ezyconfig

An environment variable based configuration loader for node projects. The motivation for this project is to minimise magic, improve communication and documentation in the form of self documenting environment variables and to encourage best (and common) practices around config loading, validation and consistency between different environments.

There are three distinct steps to using this module:

Definition

All configurations should be defined as a single function that returns an object using the variable builders provided to the config builder function. The variable builders should be used to define the type of the environment variable and any validators that should be used to ensure that the variable is of the correct type and format.

Compilation

The compilation stage is where we take the config builder function and compile it down into a real object. It is at this stage that we run the type coercions, validators and transformer functions. Any malformed or missing environment variables will throw an exception and will be immediately obvious as soon as the service starts, rather than at some point during service execution. After a config has been compiled, the developer can be sure of the types and format of the loaded configuration, freeing them up to build real business value without the concern of type casting/validation etc.

Runtime

The returned config object from the ConfigBuilder is an object that has been wrapped in proxies to provide more detailed error messages and to ensure that the loaded config remains immutable, avoiding any potentially strange behaviour.

It is recommended to dependency inject the returned config object from the ConfigBuilder but we understand that it is not always practical and/or doesn't necessarily lead to maintainable or neat code. We have therefore included a way to export a singleton configuration object so it can be imported like a normal node module.

Sometimes it is necessary to get the normal config object that has not been wrapped in a proxy so that you can pass the object down into dependencies without causing exceptions when accessing non-existent properties. To do this, you can call toJSON at any level in the config object and this will return the "real" object. Example:

const {singleton: config} = require("ezyconfig");
const ExternalLib = require("some-external-library");

const lib = new ExternalLib(config.externalLibConfig.toJSON());

lib.doSomething();

Support Utilities / Scripts

Because we have taken a declarative approach to config definition, we are able to parse the config and compute a list of required environment variables including their type, key, default value, validators etc. This means that we can simply pass the config function to the script and pass the output to the pipeline developer to configure the individual environments.

You should install this module globally to use this script:

npm i -g ezyconfig

The script usage is as follows:

explain-config [options] <configFile> [plugAndPlayFiles...]

You have several output options available:

Option Format
-o table A formatted table that is useful for copying into markdown, such as an ID Card
-o json A JSON stringified format of the environment variables
-o yml Key/Value pairs in YML format for copying into service charts

Plug and Play Environments

It is very common for multiple services to use the same resources, for example kafka, mongo, sql etc. but we might not want to force the developers of these services to keep the same config format. To support this, we have a concept of plug and play environments, where you can define a shared set of environment variables to inject into the config builder function. The advantage of these plug and play environments are as follows:

  • Less risk of typos
  • Less boilerplate in service configs
  • Common environment variable names and formats across projects
  • The developer can still craft their config as they wish
  • Same validation as config value builders

Example:

module.exports = (env, {kafka, mongo}) => ({
   mongo: {
       database: "my-project-db",
       host: mongo.host,
       user: mongo.user,
       password: mongo.password
   },
   kafka: {
       ...kafka
   },
   other: {
       variable: env.value("PROJECT_ENV_VAR", true).asBoolean()
   }
});

There are some default plug and play environments available in this module. They can be loaded like so:

const {mongo, kafka, azure, launchDarkly} = require("ezyconfig");
const {ConfigBuilder} = require("ezyconfig");

const builder = new ConfigBuilder();

builder
    .loadPlugAndPlayEnv(mongo)
    .loadPlugAndPlayEnv(kafka)
    .loadPlugAndPlayEnv(azure)
    .loadPlugAndPlayEnv(launchDarkly);

Usage

Please see the examples or tests folder for concrete examples of how to use this module. For brevity, we have listed some common examples below:

Declaring a secret value

module.exports = (env) => ({
    secretValue: env.secret("SECRET_ENV_VAR")
});

Declaring an ENV var with a default value

module.exports = (env) => ({
    someValue: env.value("ENV_VAR_NAME", "default-value")
});

Parsing into specified types

module.exports = (env) => ({
    boolValue: env.value("BOOL_ENV_VAR").asBoolean(), // BOOL_ENV_VAR=(1|0|true|false|TRUE|FALSE)
    intValue: env.value("INT_VAR").asNumber(), // INT_VAR=(1, 2, 3, ...)
    jsonObject: env.value("JSON_STRING").asObject(), // JSON_STRING={"some": "json"}
    timePeriod: env.value("TIME_PERIOD_VALUE").asInterval(), // TIME_PERIOD_VALUE=(5 seconds, 2 years, ...)
    
    // Arrays of values
    boolValueArray: env.value("ARR_BOOL_ENV_VAR").asArray(",").ofBooleans(), // ARR_BOOL_ENV_VAR=true, true, false, 0
    intValueArray: env.value("ARR_INT_VAR").asArray("|").ofNumbers(), // ARR_INT_VAR=1|2|656|4
    jsonObjectArray: env.value("ARR_JSON_STRING").asArray("|").ofObjects(), // ARR_JSON_STRING={"some": "json"}|{"hello": "world}
    timePeriodArray: env.value("ARR_TIME_PERIOD_VALUE").asArray(",").ofIntervals(), // ARR_TIME_PERIOD_VALUE=5 seconds, 2 years
});

Validating parsed values

You can provide custom validators to the validate function in the form of:

{
    name: "validatorName", // Used to provide helpful error messages
    fn: (value) => /^[a-z]$/.test(value) // Return true if the value passes validation otherwise return false
}

Otherwise you can make use of the set of validators provided in this library:

Validator Description
isAlpha Validates that the value contains only alpha chars
isAlphanumeric Validates that the value contains only alphanumeric chars
isAscii Validates that the string contains ASCII chars only
isBase32 Validates that the string is base32 encoded
isBase64 Validates that the string is base64 encoded
isBIC Validates that the string is a BIC (Bank Identification Code) or SWIFT code
isBtcAddress Validates that the string is a bitcoin address
isCurrency Validates that the string is a valid currency amount
isDataURI Validates that the string is a valid data URI format
isDate Validates that the string is a valid date
isDecimal Validates that the string represents a decimal number, such as 0.1, .3, 1.1, 1.00003, 4.0, etc
isDivisibleBy Validates that the string is a number that's divisible by another
isEAN Validates that the string is an EAN (European Article Number)
isEmail Validates that the string is a valid email address
isFQDN Validates that the string is a fully qualified domain name
isHexadecimal Validates that the string is a hexadecimal number
isHexColor Validates that the string is a hexadecimal color
isHSL Validates that the string is an HSL (hue, saturation, lightness, optional alpha) color based on CSS Colors Level 4 specification
isIBAN Validates that the string is a IBAN (International Bank Account Number)
isIP Validates that the string is an IP (version 4 or 6)
isIPRange Validates that the string is an IP Range (version 4 or 6)
isISO8601 Validates that the string is a valid ISO 8601 date
isISO31661Alpha2 Validates that the string is a valid ISO 3166-1 alpha-2 officially assigned country code
isISO31661Alpha3 Validates that the string is a valid ISO 3166-1 alpha-3 officially assigned country code
isJWT Validates that the string is valid JWT token
isLocale Validates that the string is a locale
isLowercase Validates that the string is lowercase
isMACAddress Validates that the string is a MAC address
isMD5 Validates that the string is a MD5 hash
isMimeType Validates that string matches to a valid MIME type format
isMongoId Validates that the string is a valid hex-encoded representation of a MongoDB ObjectId
isMultibyte Validates that the string contains one or more multibyte chars
isNumeric Validates that the string contains only numbers
isOctal Validates that the string is a valid octal number
isRFC3339 Validates that the string is a valid RFC 3339 date
isRgbColor Validates that the string is a rgb or rgba color
isSemVer Validates that the string is a Semantic Versioning Specification (SemVer)
isUppercase Validates that the string is uppercase
isSlug Validates that the string is of type slug
isURL Validates that the string is an URL
isUUID Validates that the string is a UUID (version 3, 4 or 5)
isVariableWidth Validates that the string contains a mixture of full and half-width chars
isPort Validates that the value given is a valid port number
fileExists Validates that the file path described exists - This is useful for service dependencies such as certificates etc

Example:

module.exports = (env) => ({
    servicePort: env.value("SERVICE_PORT").validate(env.validators.isPort),
    certificatePath: env.value("CERT_PATH").validate(env.validators.fileExists)
});