diff --git a/package-lock.json b/package-lock.json index e5971d2..6719f52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -932,6 +932,12 @@ "integrity": "sha512-/kQvbVzdEpOq4tEWT79yAHSM4nH4xMlhJv2GrLVQt4Qmo8yYsPdioBM1QpN/2GX1wkfMnyXvdoftvLUr0LBj7Q==", "dev": true }, + "@types/js-yaml": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.0.tgz", + "integrity": "sha512-UGEe/6RsNAxgWdknhzFZbCxuYc5I7b/YEKlfKbo+76SM8CJzGs7XKCj7zyugXViRbKYpXhSXhCYVQZL5tmDbpQ==", + "dev": true + }, "@types/mime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.0.tgz", diff --git a/package.json b/package.json index a42be7b..481e6b6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@types/express-session": "^1.15.11", "@types/http-errors": "^1.6.1", "@types/jest": "^23.3.12", + "@types/js-yaml": "^3.12.0", "@types/node": "^10.12.18", "@types/passport": "^1.0.0", "@types/passport-local": "^1.0.33", @@ -26,10 +27,6 @@ "@types/supertest": "^2.0.7", "@types/uuid": "^3.4.4", "axios": "^0.18.0", - "react": "^16.7.0", - "react-dom": "^16.7.0", - "react-redux": "^6.0.0", - "redux": "^4.0.1", "browserify": "^16.2.3", "buildfile": "^1.2.17", "jest": "^23.6.0", @@ -37,6 +34,10 @@ "loose-envify": "^1.4.0", "nodemon": "^1.18.9", "npm-run-all": "^4.1.5", + "react": "^16.7.0", + "react-dom": "^16.7.0", + "react-redux": "^6.0.0", + "redux": "^4.0.1", "std-mocks": "^1.0.1", "supertest": "^3.3.0", "terser": "^3.14.1", diff --git a/packages/server/src/config/Config.ts b/packages/server/src/config/Config.ts new file mode 100644 index 0000000..b861bdd --- /dev/null +++ b/packages/server/src/config/Config.ts @@ -0,0 +1,29 @@ +export class Config { + constructor(protected readonly config: any) {} + + get(key: string) { + let value = this.config + key.split('.').forEach(k => { + if (!value.hasOwnProperty(k)) { + throw new Error(`Property "${k}" from "${key}" does not exist`) + } + value = value[k] + }) + return value + } + + has(key: string) { + let c = this.config + return key.split('.').every(k => { + const has = c.hasOwnProperty(k) + if (has) { + c = c[k] + } + return has + }) + } + + value() { + return this.config + } +} diff --git a/packages/server/src/config/ConfigReader.test.ts b/packages/server/src/config/ConfigReader.test.ts new file mode 100644 index 0000000..9db6181 --- /dev/null +++ b/packages/server/src/config/ConfigReader.test.ts @@ -0,0 +1,74 @@ +import {ConfigReader} from './ConfigReader' +import {join} from 'path' +import {closeSync, openSync} from 'fs' + +describe('ConfigReader', () => { + + beforeAll(() => { + closeSync( + openSync( + join(__dirname, 'test-files', 'package.json'), + 'w', + ), + ) + }) + + describe('read', () => { + + it('reads and merges configuration files from package root', () => { + const config = new ConfigReader(__dirname).read() + expect(config.get('app.name')).toEqual(jasmine.any(String)) + }) + + it('reads and merges configuration files from CWD', () => { + const config = new ConfigReader( + join(__dirname, 'test-files', 'dir'), + '/tmp/path', + ).read() + expect(config.value()).toEqual({ + a: 1, + b: 'test', + c: { + d: 'value', + e: ['entry 3'], + f: 'extra value', + }, + }) + }) + + it('fails when not a single config file found', () => { + expect(() => new ConfigReader('/tmp/test', '/tmp/test').read()) + .toThrowError('No config files found') + }) + + it('succeeds when custom filename is read', () => { + expect(() => new ConfigReader('/tmp/test', '/tmp/test').read()) + .toThrowError('No config files found') + }) + + describe('environment variable', () => { + const origConfig = process.env.CONFIG + afterEach(() => { + process.env.CONFIG = origConfig + }) + + it('succeeds when config from env variable is read', () => { + process.env.CONFIG = '---\na: 2' + const config = new ConfigReader( + join(__dirname, 'test-files', 'dir'), + '/tmp/path', + ).read() + expect(config.value()).toEqual({ + a: 2, + b: 'test', + c: { + d: 'value', + e: ['entry 3'], + f: 'extra value', + }, + }) + }) + }) + + }) +}) diff --git a/packages/server/src/config/ConfigReader.ts b/packages/server/src/config/ConfigReader.ts new file mode 100644 index 0000000..571f994 --- /dev/null +++ b/packages/server/src/config/ConfigReader.ts @@ -0,0 +1,107 @@ +import YAML from 'js-yaml' +import {Config} from './Config' +import {findPackageRoot} from '../files/Find' +import {join} from 'path' +import {readFileSync} from 'fs' + +const isObject = (value: any) => value !== null && typeof value === 'object' + +export class ConfigReader { + protected readonly config: any = {} + protected readonly locations: string[] + protected readonly filenames: string[] + + constructor( + readonly path: string, + readonly cwd: string|undefined = process.cwd(), + readonly environment = 'CONFIG', + ) { + const packageRoot = path && findPackageRoot(path) + this.locations = packageRoot ? [packageRoot] : [] + if (cwd && cwd !== packageRoot) { + this.locations.push(cwd) + } + this.filenames = [ + 'default.yml', + `${process.env.NODE_ENV || 'development'}.yml`, + ] + } + + read(filename?: string) { + let success = 0 + for (const location of this.locations) { + for (const fname of this.filenames) { + const configFilename = join(location, 'config', fname) + try { + this.readFile(configFilename) + } catch (err) { + if (err.code !== 'ENOENT') { + throw err + } + continue + } + success += 1 + } + } + + const env = process.env[this.environment] + if (!filename && !env && !success) { + throw new Error('No config files found') + } + + if (filename) { + this.readFile(filename) + } + + if (env) { + this.parse(env) + } + + return new Config(this.config) + } + + readFile(filename: string) { + const yaml = readFileSync(filename, 'utf-8') + const config = YAML.safeLoad(yaml) + if (config) { + this.mergeConfig(config, this.config) + } + } + + parse(yaml: string) { + const config = YAML.safeLoad(yaml) + this.mergeConfig(config, this.config) + } + + protected mergeConfig(source: any, destination: any) { + const stack = [{src: source, dest: destination}] + + // let i = 0 + while (stack.length) { + // i++ + const {src, dest} = stack.pop()! + const keys = Object.keys(src) + + keys.forEach(key => { + // if (i > 100) { + // throw new Error('overload') + // } + const value = src[key] + if (isObject(value) && !Array.isArray(value)) { + if (!dest.hasOwnProperty(key) || + Array.isArray(dest[key]) || + !isObject(dest[key]) + ) { + dest[key] = {} + } + stack.push({src: value, dest: dest[key]}) + return + } + dest[key] = value + }) + } + + return destination + } + +} diff --git a/packages/server/src/config/test-files/.gitignore b/packages/server/src/config/test-files/.gitignore new file mode 100644 index 0000000..ec6d3cd --- /dev/null +++ b/packages/server/src/config/test-files/.gitignore @@ -0,0 +1 @@ +package.json diff --git a/packages/server/src/config/test-files/config/default.yml b/packages/server/src/config/test-files/config/default.yml new file mode 100644 index 0000000..e43dc2c --- /dev/null +++ b/packages/server/src/config/test-files/config/default.yml @@ -0,0 +1,6 @@ +a: 1 +b: 'test' +c: + d: value + e: + - entry diff --git a/packages/server/src/config/test-files/config/test.yml b/packages/server/src/config/test-files/config/test.yml new file mode 100644 index 0000000..60badaa --- /dev/null +++ b/packages/server/src/config/test-files/config/test.yml @@ -0,0 +1,5 @@ +c: + d: value + e: + - entry 3 + f: extra value diff --git a/packages/server/src/files/Find.ts b/packages/server/src/files/Find.ts new file mode 100644 index 0000000..6e04576 --- /dev/null +++ b/packages/server/src/files/Find.ts @@ -0,0 +1,30 @@ +import {resolve, join} from 'path' +import {statSync, Stats} from 'fs' + +export function findNearestDirectory( + dir: string, filename: string, +) { + let currentDir = dir + let lastDir = dir + do { + let s: Stats + try { + s = statSync(join(currentDir, filename)) + } catch (err) { + lastDir = currentDir + currentDir = resolve(currentDir, '..') + continue + } + if (!s.isFile()) { + lastDir = currentDir + currentDir = resolve(currentDir, '..') + continue + } + + return currentDir + } while (lastDir !== currentDir) +} + +export function findPackageRoot(dir: string = __dirname) { + return findNearestDirectory(dir, 'package.json') +}