Skip to content

Commit c1743dc

Browse files
author
Miroslav Bajtoš
committed
Load datasources and app cfg from multiple files
Modify loading of `appConfig` and `dataSourceConfig` to look for the following files: - app.json - app.local.{js|json} - app.{$env}.{js|json} - datasources.json - datasources.local.{js|json} - datasources.{$env}.{js|json} where $env is the value of `app.get('env')`, which usually defaults to `process.env.NODE_ENV`. The values in the additional files are applied to the config object, overwritting any existing values. The new values must be value types like String or Number; Object and Array are not supported. Additional datasource config files cannot define new datasources, only modify existing ones. The commit includes refactoring of the config-loading code into a standalone file.
1 parent a4402a3 commit c1743dc

6 files changed

Lines changed: 319 additions & 23 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
node_modules
1414
checkstyle.xml
1515
loopback-boot-*.tgz
16+
/test/sandbox/

index.js

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ var assert = require('assert');
22
var fs = require('fs');
33
var path = require('path');
44
var _ = require('underscore');
5+
var ConfigLoader = require('./lib/config-loader');
56

67
/**
78
* Initialize an application from an options object or
@@ -103,27 +104,20 @@ var _ = require('underscore');
103104
* @header boot(app, [options])
104105
*/
105106

106-
module.exports = function bootLoopBackApp(app, options) {
107+
exports = module.exports = function bootLoopBackApp(app, options) {
107108
/*jshint camelcase:false */
108109
options = options || {};
109110

110111
if(typeof options === 'string') {
111112
options = { appRootDir: options };
112113
}
113114
var appRootDir = options.appRootDir = options.appRootDir || process.cwd();
114-
var appConfig = options.app;
115-
var modelConfig = options.models;
116-
var dataSourceConfig = options.dataSources;
115+
var env = app.get('env');
117116

118-
if(!appConfig) {
119-
appConfig = tryReadConfig(appRootDir, 'app') || {};
120-
}
121-
if(!modelConfig) {
122-
modelConfig = tryReadConfig(appRootDir, 'models') || {};
123-
}
124-
if(!dataSourceConfig) {
125-
dataSourceConfig = tryReadConfig(appRootDir, 'datasources') || {};
126-
}
117+
var appConfig = options.app || ConfigLoader.loadAppConfig(appRootDir, env);
118+
var modelConfig = options.models || ConfigLoader.loadModels(appRootDir, env);
119+
var dataSourceConfig = options.dataSources ||
120+
ConfigLoader.loadDataSources(appRootDir, env);
127121

128122
assertIsValidConfig('app', appConfig);
129123
assertIsValidConfig('model', modelConfig);
@@ -298,12 +292,4 @@ function tryReadDir() {
298292
}
299293
}
300294

301-
function tryReadConfig(cwd, fileName) {
302-
try {
303-
return require(path.join(cwd, fileName + '.json'));
304-
} catch(e) {
305-
if(e.code !== 'MODULE_NOT_FOUND') {
306-
throw e;
307-
}
308-
}
309-
}
295+
exports.ConfigLoader = ConfigLoader;

lib/config-loader.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
var fs = require('fs');
2+
var path = require('path');
3+
4+
var ConfigLoader = exports;
5+
6+
/**
7+
* Load application config from `app.json` and friends.
8+
* @param {String} rootDir Directory where to look for files.
9+
* @param {String} env Environment, usually `process.env.NODE_ENV`
10+
* @returns {Object}
11+
*/
12+
ConfigLoader.loadAppConfig = function(rootDir, env) {
13+
return loadNamed(rootDir, env, 'app', mergeAppConfig);
14+
};
15+
16+
/**
17+
* Load data-sources config from `datasources.json` and friends.
18+
* @param {String} rootDir Directory where to look for files.
19+
* @param {String} env Environment, usually `process.env.NODE_ENV`
20+
* @returns {Object}
21+
*/
22+
ConfigLoader.loadDataSources = function(rootDir, env) {
23+
return loadNamed(rootDir, env, 'datasources', mergeDataSourceConfig);
24+
};
25+
26+
/**
27+
* Load models config from `models.json` and friends.
28+
* @param {String} rootDir Directory where to look for files.
29+
* @param {String} env Environment, usually `process.env.NODE_ENV`
30+
* @returns {Object}
31+
*/
32+
ConfigLoader.loadModels = function(rootDir, env) {
33+
/*jshint unused:false */
34+
return tryReadJsonConfig(rootDir, 'models') || {};
35+
};
36+
37+
/*-- Implementation --*/
38+
39+
/**
40+
* Load named configuration.
41+
* @param {String} rootDir Directory where to look for files.
42+
* @param {String} env Environment, usually `process.env.NODE_ENV`
43+
* @param {String} name
44+
* @param {function(target:Object, config:Object, filename:String)} mergeFn
45+
* @returns {Object}
46+
*/
47+
function loadNamed(rootDir, env, name, mergeFn) {
48+
var files = findConfigFiles(rootDir, env, name);
49+
var configs = loadConfigFiles(files);
50+
return mergeConfigurations(configs, mergeFn);
51+
}
52+
53+
/**
54+
* Search `appRootDir` for all files containing configuration for `name`.
55+
* @param {String} appRootDir
56+
* @param {String} env Environment, usually `process.env.NODE_ENV`
57+
* @param {String} name
58+
* @returns {Array.<String>} Array of absolute file paths.
59+
*/
60+
function findConfigFiles(appRootDir, env, name) {
61+
var master = ifExists(name + '.json');
62+
if (!master) return [];
63+
64+
var candidates = [
65+
master,
66+
ifExistsWithAnyExt(name + '.local'),
67+
ifExistsWithAnyExt(name + '.' + env)
68+
];
69+
70+
return candidates.filter(function(c) { return c !== undefined; });
71+
72+
function ifExists(fileName) {
73+
var filepath = path.resolve(appRootDir, fileName);
74+
return fs.existsSync(filepath) ? filepath : undefined;
75+
}
76+
77+
function ifExistsWithAnyExt(fileName) {
78+
return ifExists(fileName + '.js') || ifExists(fileName + '.json');
79+
}
80+
}
81+
82+
/**
83+
* Load configuration files into an array of objects.
84+
* Attach non-enumerable `_filename` property to each object.
85+
* @param {Array.<String>} files
86+
* @returns {Array.<Object>}
87+
*/
88+
function loadConfigFiles(files) {
89+
return files.map(function(f) {
90+
var config = require(f);
91+
Object.defineProperty(config, '_filename', {
92+
enumerable: false,
93+
value: f
94+
});
95+
return config;
96+
});
97+
}
98+
99+
/**
100+
* Merge multiple configuration objects into a single one.
101+
* @param {Array.<Object>} configObjects
102+
* @param {function(target:Object, config:Object, filename:String)} mergeFn
103+
*/
104+
function mergeConfigurations(configObjects, mergeFn) {
105+
var result = configObjects.shift() || {};
106+
while(configObjects.length) {
107+
var next = configObjects.shift();
108+
mergeFn(result, next, next._filename);
109+
}
110+
return result;
111+
}
112+
113+
function mergeDataSourceConfig(target, config, fileName) {
114+
for (var ds in target) {
115+
var err = applyCustomConfig(target[ds], config[ds]);
116+
if (err) {
117+
throw new Error('Cannot apply ' + fileName + ' to `' + ds + '`: ' + err);
118+
}
119+
}
120+
}
121+
122+
function mergeAppConfig(target, config, fileName) {
123+
var err = applyCustomConfig(target, config);
124+
if (err) {
125+
throw new Error('Cannot apply ' + fileName + ': ' + err);
126+
}
127+
}
128+
129+
function applyCustomConfig(target, config) {
130+
for (var key in config) {
131+
var value = config[key];
132+
if (typeof value === 'object') {
133+
return 'override for the option `' + key + '` is not a value type.';
134+
}
135+
target[key] = value;
136+
}
137+
return null; // no error
138+
}
139+
140+
/**
141+
* Try to read a config file with .json extension
142+
* @param cwd Dirname of the file
143+
* @param fileName Name of the file without extension
144+
* @returns {Object|undefined} Content of the file, undefined if not found.
145+
*/
146+
function tryReadJsonConfig(cwd, fileName) {
147+
try {
148+
return require(path.join(cwd, fileName + '.json'));
149+
} catch(e) {
150+
if(e.code !== 'MODULE_NOT_FOUND') {
151+
throw e;
152+
}
153+
}
154+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
"loopback": "^1.5.0",
2929
"mocha": "^1.19.0",
3030
"must": "^0.11.0",
31-
"supertest": "^0.13.0"
31+
"supertest": "^0.13.0",
32+
"fs-extra": "^0.9.1"
3233
},
3334
"peerDependencies": {
3435
"loopback": "1.x"

test/boot.test.js

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
var boot = require('../');
2+
var fs = require('fs-extra');
3+
var extend = require('util')._extend;
24
var path = require('path');
35
var loopback = require('loopback');
46
var assert = require('assert');
57
var expect = require('must');
8+
var sandbox = require('./helpers/sandbox');
69

710
var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app');
811

12+
var appDir;
13+
914
describe('bootLoopBackApp', function() {
15+
beforeEach(sandbox.reset);
16+
17+
beforeEach(function makeUniqueAppDir(done) {
18+
// Node's module loader has a very aggressive caching, therefore
19+
// we can't reuse the same path for multiple tests
20+
// The code here is used to generate a random string
21+
require('crypto').randomBytes(5, function(ex, buf) {
22+
var randomStr = buf.toString('hex');
23+
appDir = sandbox.resolve(randomStr);
24+
done();
25+
});
26+
});
27+
1028
describe('from options', function () {
1129
var app;
1230
beforeEach(function () {
@@ -186,6 +204,102 @@ describe('bootLoopBackApp', function() {
186204
assert.isFunc(app.models.Foo, 'find');
187205
assert.isFunc(app.models.Foo, 'create');
188206
});
207+
208+
it('merges datasource configs from multiple files', function() {
209+
givenAppInSandbox();
210+
211+
writeAppConfigFile('datasources.local.json', {
212+
db: { local: 'applied' }
213+
});
214+
215+
var env = process.env.NODE_ENV || 'development';
216+
writeAppConfigFile('datasources.' + env + '.json', {
217+
db: { env: 'applied' }
218+
});
219+
220+
var app = loopback();
221+
boot(app, appDir);
222+
223+
var db = app.datasources.db.settings;
224+
expect(db).to.have.property('local', 'applied');
225+
expect(db).to.have.property('env', 'applied');
226+
227+
var expectedLoadOrder = ['local', 'env'];
228+
var actualLoadOrder = Object.keys(db).filter(function(k) {
229+
return expectedLoadOrder.indexOf(k) !== -1;
230+
});
231+
232+
expect(actualLoadOrder, 'load order').to.eql(expectedLoadOrder);
233+
});
234+
235+
it('supports .js for custom datasource config files', function() {
236+
givenAppInSandbox();
237+
fs.writeFileSync(
238+
path.resolve(appDir, 'datasources.local.js'),
239+
'module.exports = { db: { fromJs: true } };');
240+
241+
var app = loopback();
242+
boot(app, appDir);
243+
244+
var db = app.datasources.db.settings;
245+
expect(db).to.have.property('fromJs', true);
246+
});
247+
248+
it('refuses to merge Object properties', function() {
249+
givenAppInSandbox();
250+
writeAppConfigFile('datasources.local.json', {
251+
db: { nested: { key: 'value' } }
252+
});
253+
254+
var app = loopback();
255+
expect(function() { boot(app, appDir); })
256+
.to.throw(/`nested` is not a value type/);
257+
});
258+
259+
it('refuses to merge Array properties', function() {
260+
givenAppInSandbox();
261+
writeAppConfigFile('datasources.local.json', {
262+
db: { nested: ['value'] }
263+
});
264+
265+
var app = loopback();
266+
expect(function() { boot(app, appDir); })
267+
.to.throw(/`nested` is not a value type/);
268+
});
269+
270+
it('merges app configs from multiple files', function() {
271+
givenAppInSandbox();
272+
273+
writeAppConfigFile('app.local.json', { cfgLocal: 'applied' });
274+
275+
var env = process.env.NODE_ENV || 'development';
276+
writeAppConfigFile('app.' + env + '.json', { cfgEnv: 'applied' });
277+
278+
var app = loopback();
279+
boot(app, appDir);
280+
281+
expect(app.settings).to.have.property('cfgLocal', 'applied');
282+
expect(app.settings).to.have.property('cfgEnv', 'applied');
283+
284+
var expectedLoadOrder = ['cfgLocal', 'cfgEnv'];
285+
var actualLoadOrder = Object.keys(app.settings).filter(function(k) {
286+
return expectedLoadOrder.indexOf(k) !== -1;
287+
});
288+
289+
expect(actualLoadOrder, 'load order').to.eql(expectedLoadOrder);
290+
});
291+
292+
it('supports .js for custom app config files', function() {
293+
givenAppInSandbox();
294+
fs.writeFileSync(
295+
path.resolve(appDir, 'app.local.js'),
296+
'module.exports = { fromJs: true };');
297+
298+
var app = loopback();
299+
boot(app, appDir);
300+
301+
expect(app.settings).to.have.property('fromJs', true);
302+
});
189303
});
190304
});
191305

@@ -206,3 +320,27 @@ assert.isFunc = function (obj, name) {
206320
' on object that does not exist');
207321
assert(typeof obj[name] === 'function', name + ' is not a function');
208322
};
323+
324+
function givenAppInSandbox(appConfig, dataSources, models) {
325+
fs.mkdirsSync(appDir);
326+
327+
appConfig = extend({
328+
}, appConfig);
329+
writeAppConfigFile('app.json', appConfig);
330+
331+
dataSources = extend({
332+
db: {
333+
connector: 'memory',
334+
defaultForType: 'db'
335+
}
336+
}, dataSources);
337+
writeAppConfigFile('datasources.json', dataSources);
338+
339+
models = extend({
340+
}, models);
341+
writeAppConfigFile('models.json', models);
342+
}
343+
344+
function writeAppConfigFile(name, json) {
345+
fs.writeJsonFileSync(path.resolve(appDir, name), json);
346+
}

0 commit comments

Comments
 (0)