Skip to content

Commit

Permalink
feat(overrides): add ability to define extra overrides
Browse files Browse the repository at this point in the history
  • Loading branch information
Raphaël Benitte committed Sep 14, 2017
1 parent f67e248 commit ed1f0d5
Show file tree
Hide file tree
Showing 9 changed files with 353 additions and 109 deletions.
25 changes: 20 additions & 5 deletions README.md
Expand Up @@ -11,8 +11,9 @@ A lightweight/opinionated/versatile configuration module powered by yaml.
- [Installation](#installation)
- [Usage](#usage)
- [Environment variables override](#environment-variables-override)
- [NODE_ENV override](#node-env-override)
- [More overrides :smiling_imp:](#more-overrides)
- [NODE_ENV override](#node_env-override)
- [More overrides](#more-overrides) :smiling_imp:
- [Inheritance model](#inheritance-model)

## Motivation

Expand All @@ -33,7 +34,7 @@ we wanted simple config management with the following features:
- Env variables support

Several modules already exist, but none of them matched our requirements,
some we're far too limited and others were, in our opinion, really bloated.
some were far too limited and others, in our opinion, really bloated.

We chose yaml because it automatically covered several requirements,
it's concise compared to json, you can add comments, it supports types
Expand All @@ -47,6 +48,7 @@ everyone adding its favorite flavor.
Then we used environment variables to load overrides or define some specific keys,
it makes really easy to tweak your config depending on the environment
you're running on without touching a single line of code or even a config file.
Really handy when using Docker heavily.

We used this code across several projects (a small file comprised of ~100 loc at this time),
and improved it when required.
Expand Down Expand Up @@ -217,7 +219,7 @@ you have another available level of override using `CONF_OVERRIDES`.
> Please make sure you really need it before using it
> as it makes more unclear what the final config will be.
Let's say we'got those config files:
Let's say we've got those config files:

```yaml
# /conf/base.yaml
Expand Down Expand Up @@ -296,11 +298,24 @@ if you take this example:
NODE_ENV=prod CONF_OVERRIDES=aws,prod node test.js
```

the second `prod` defined inside `CONF_OVERRIDES` will be ignored as it has been already loaded
the second `prod` value defined inside `CONF_OVERRIDES` will be ignored as it has been already loaded
because of `NODE_ENV=prod`.

:warning: The `env_mapping.yaml` will always take precedence over files overrides.

### Inheritance model

```
base.yaml <— [<NODE_ENV>.yaml] <— [<CONF_OVERRIDES>.yaml] <— [env_mapping.yaml]
```

*All files surrounded by `[]` are optional.*

1. Load config from `base.yaml`
2. If `NODE_ENV` is defined & `<NODE_ENV>.yaml` exists, load it
3. If `CONF_OVERRIDES` is defined, load each corresponding file if it exists
4. If `env_mapping.yaml` exists and some environment variables match, override with those values

[npm-image]: https://img.shields.io/npm/v/@ekino/config.svg?style=flat-square
[npm-url]: https://www.npmjs.com/package/@ekino/config
[travis-image]: https://img.shields.io/travis/ekino/node-config.svg?style=flat-square
Expand Down
14 changes: 11 additions & 3 deletions conf/base.yaml
@@ -1,4 +1,12 @@
port: 8080
#
# This is the base config file, loaded for all tests
#
port: 8080
version: 0.0.0
name: test-app0
uuid: 01A0
name: test-app0
uuid: 01A0
api:
credentials:
id: base-api-id
key: base-api-key
retries: 3
21 changes: 16 additions & 5 deletions conf/env_mapping.yaml
@@ -1,16 +1,27 @@
#
# Defines environment mapping, loaded for all tests
#
PORT:
key: port
key: port
type: number

NAME:
key: name

VERSION: version

ID:
key: id
key: id
type: number

USE_SSL:
key: useSsl
key: useSsl
type: boolean

USE_MOCKS:
key: useMocks
key: useMocks
type: boolean


API_RETRIES:
key: api.retries
type: number
6 changes: 6 additions & 0 deletions conf/override_a.yaml
@@ -0,0 +1,6 @@
port: 8082
version: 0.0.2
api:
credentials:
key: override-a-api-key

4 changes: 4 additions & 0 deletions conf/override_b.yaml
@@ -0,0 +1,4 @@
version: 0.0.3
api:
credentials:
key: override-b-api-key
7 changes: 5 additions & 2 deletions conf/prod.yaml
@@ -1,3 +1,6 @@
port: 8081
port: 8081
version: 0.0.1
env: prod
env: prod
api:
credentials:
key: prod-api-key
146 changes: 95 additions & 51 deletions index.js
Expand Up @@ -5,15 +5,7 @@ const yaml = require('js-yaml')
const fs = require('fs')
const path = require('path')

const internals = {}

internals.cfg = {}

internals.confPath = process.env.NODE_CONFIG_DIR ? `${process.env.NODE_CONFIG_DIR}` : path.join(process.cwd(), 'conf')

internals.basePath = path.join(internals.confPath, 'base.yaml')
internals.envMappingPath = path.join(internals.confPath, 'env_mapping.yaml')
internals.overridesPath = process.env.NODE_ENV ? path.join(internals.confPath, `${process.env.NODE_ENV}.yaml`) : null
const internals = { cfg: {} }

/**
* Get a value from the configuration. Supports dot notation (eg: "key.subkey.subsubkey")...
Expand Down Expand Up @@ -49,27 +41,56 @@ exports.dump = () => internals.cfg
/** ***** Internals **********/

/**
* Read a yaml file and convert it to json.
* WARNING : This use a sync function to read file
* Read a yaml file and convert it to javascript object.
*
* WARNING: This use a sync function to read file
*
* @param {string} path
* @return {Object}
*/
internals.read = path => {
const content = fs.readFileSync(path, { encoding: 'utf8' })
let result = yaml.safeLoad(content)

return result
}

/**
* Try to load a yaml file, if the file does not exist, return null.
*
* @see internals.read
*
* @param {string} path
* @return {Object|null}
*/
internals.readEventually = path => {
try {
const content = internals.read(path)
return content
} catch (e) {
if (!e || e.code !== 'ENOENT') throw e
return null
}
}

/**
* Cast a value using given type.
*
* @param {string} type
* @param {string} value
* @returns {string|number|boolean} Casted value
*/
internals.cast = (type, value) => {
switch (type) {
case 'number': {
const result = Number(value)
if (_.isNaN(result)) throw new Error(`Config error : expected a number got ${value}`)
if (_.isNaN(result)) throw new Error(`Config error: expected a number got ${value}`)

return result
}
case 'boolean':
if (!_.includes(['true', 'false', '0', '1'], value)) throw new Error(`Config error : expected a boolean got ${value}`)
if (!_.includes(['true', 'false', '0', '1'], value))
throw new Error(`Config error: expected a boolean got ${value}`)

return value === 'true' || value === '1'
default:
Expand All @@ -78,62 +99,85 @@ internals.cast = (type, value) => {
}

/**
* Read env variables override file and set config from env vars
* Read env variables override file and set config from env vars.
*
* @param {Object} mappings
* @return {Object}
*/
internals.readEnvOverrides = () => {
const result = {}
internals.getEnvOverrides = mappings => {
const overriden = {}

try {
const content = internals.read(internals.envMappingPath)
_.forOwn(content, (mapping, key) => {
if (_.isUndefined(process.env[key])) return true

let value = process.env[key]
let mappedKey = mapping

if (mapping.key) {
mappedKey = mapping.key
value = internals.cast(mapping.type, value)
}
_.set(result, mappedKey, value)
})
} catch (e) {
if (!e || e.code !== 'ENOENT') throw e
}
_.forOwn(mappings, (mapping, key) => {
if (_.isUndefined(process.env[key])) return true

return result
let value = process.env[key]
let mappedKey = mapping

if (mapping.key) {
mappedKey = mapping.key
value = internals.cast(mapping.type, value)
}
_.set(overriden, mappedKey, value)
})

return overriden
}

/**
* Return the source value if it is an array
* This function is used to customize the default output of _.mergeWith
* Return the source value if it is an array.
* This function is used to customize the default output of `_.mergeWith()`.
*
* @param {*} objValue: the target field content
* @param {*} srcValue: the new value
* @returns {*}: return what we want as a value, or undefined to let the default behaviour kick in
* @returns {*} return what we want as a value, or undefined to let the default behaviour kick in
*/
internals.customizer = (objValue, srcValue) => {
return _.isArray(srcValue) ? srcValue : undefined
}
internals.customizer = (objValue, srcValue) => (_.isArray(srcValue) ? srcValue : undefined)

internals.merge = _.partialRight(_.mergeWith, internals.customizer)

/**
* Read base file, override it with env file and finally override it with env vars
* Loads config:
*
* 1. load base.yaml
* 2. if NODE_ENV is defined and a config file matching its value exists, load it
* 3. if CONF_OVERRIDES is defined, try to load corresponding files
* 4. load env_mapping.yaml if it exists and search for overrides
*/
internals.load = () => {
const base = internals.read(internals.basePath)
let env = {}
if (internals.overridesPath) {
try {
env = internals.read(internals.overridesPath)
} catch (e) {
if (!e || e.code !== 'ENOENT') throw e
}
const confPath = process.env.NODE_CONFIG_DIR
? `${process.env.NODE_CONFIG_DIR}`
: path.join(process.cwd(), 'conf')

// load base config (required)
const baseConfig = internals.read(path.join(confPath, 'base.yaml'))
internals.cfg = Object.assign({}, baseConfig)

// apply file overrides (optional)
let overrideFiles = []
if (process.env.NODE_ENV) overrideFiles.push(process.env.NODE_ENV)
if (process.env.CONF_OVERRIDES) {
overrideFiles = overrideFiles.concat(
process.env.CONF_OVERRIDES.split(',').filter(
// remove garbage and prevent dupes
override =>
!_.isEmpty(override) && override !== 'base' && !overrideFiles.includes(override)
)
)
}

const envOverrides = internals.readEnvOverrides()
overrideFiles.forEach(overrideFile => {
const overridePath = path.join(confPath, `${overrideFile}.yaml`)
const override = internals.readEventually(overridePath)

internals.cfg = _.mergeWith({}, base, env, envOverrides, internals.customizer)
if (override !== null) internals.merge(internals.cfg, override)
})

// apply environment overrides (optional)
const envOverridesConfig = internals.readEventually(path.join(confPath, 'env_mapping.yaml'))
if (envOverridesConfig !== null) {
const envOverrides = internals.getEnvOverrides(envOverridesConfig)
internals.merge(internals.cfg, envOverrides)
}
}

internals.load()
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -39,8 +39,8 @@
"prettier": "1.x.x"
},
"scripts": {
"fmt": "prettier --print-width 140 --tab-width=4 --single-quote --bracket-spacing --no-semi --color --write index.js test/*.js test/**/*.js",
"check-fmt": "prettier --print-width 140 --tab-width=4 --single-quote --bracket-spacing --no-semi --list-different index.js test/*.js test/**/*.js",
"fmt": "prettier --print-width 100 --tab-width=4 --single-quote --bracket-spacing --no-semi --color --write index.js test/*.js test/**/*.js",
"check-fmt": "prettier --print-width 100 --tab-width=4 --single-quote --bracket-spacing --no-semi --list-different index.js test/*.js test/**/*.js",
"test": "ava",
"test-cover": "nyc ava",
"coverage": "nyc report --reporter=text-lcov | coveralls",
Expand Down

0 comments on commit ed1f0d5

Please sign in to comment.