diff --git a/.gitignore b/.gitignore index 72a3f817effe018..b969fc1a7b5d0b3 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ selenium *.out ui_framework/doc_site/build/*.js* yarn.lock +.vscode/ diff --git a/package.json b/package.json index 2bd5321e137d278..c171c514859ae08 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,9 @@ "mocha": "mocha", "mocha:debug": "mocha --debug-brk", "sterilize": "grunt sterilize", - "uiFramework:start": "grunt uiFramework:start" + "uiFramework:start": "grunt uiFramework:start", + "ts:build": "tsc", + "ts:start": "tsc --watch" }, "repository": { "type": "git", @@ -124,6 +126,7 @@ "expiry-js": "0.1.7", "exports-loader": "0.6.2", "expose-loader": "0.7.0", + "express": "4.15.2", "extract-text-webpack-plugin": "0.8.2", "file-loader": "0.8.4", "flot-charts": "0.8.3", @@ -184,6 +187,7 @@ "rimraf": "2.4.3", "rison-node": "1.0.0", "rjs-repack-loader": "1.0.6", + "rxjs": "5.3.0", "script-loader": "0.6.1", "semver": "5.1.0", "style-loader": "0.12.3", @@ -191,6 +195,7 @@ "tinygradient": "0.3.0", "trunc-html": "1.0.2", "trunc-text": "1.0.2", + "type-detect": "4.0.0", "ui-select": "0.19.6", "url-loader": "0.5.6", "validate-npm-package-name": "2.2.2", @@ -198,15 +203,25 @@ "webpack": "github:elastic/webpack#fix/query-params-for-aliased-loaders", "whatwg-fetch": "0.9.0", "wreck": "6.2.0", + "yargs": "7.0.2", "yauzl": "2.7.0" }, "devDependencies": { "@elastic/eslint-config-kibana": "0.5.0", "@spalger/babel-presets": "0.3.2", + "@types/chalk": "0.4.31", + "@types/express": "4.0.35", + "@types/jest": "19.2.2", + "@types/js-yaml": "3.5.29", + "@types/lodash": "3.10.1", + "@types/node": "6.0.68", + "@types/sinon": "1.16.36", + "@types/yargs": "6.6.0", "angular-mocks": "1.4.7", "auto-release-sinon": "1.0.3", "babel-eslint": "6.1.2", "chai": "3.5.0", + "chalk": "1.1.3", "chance": "1.0.6", "cheerio": "0.22.0", "chokidar": "1.6.0", @@ -275,6 +290,8 @@ "source-map-support": "0.2.10", "supertest": "1.2.0", "supertest-as-promised": "2.0.2", + "ts-jest": "19.0.13", + "typescript": "2.2.2", "webpack-dev-server": "1.14.1" }, "engines": { diff --git a/platform/README.md b/platform/README.md new file mode 100644 index 000000000000000..823dde01c2b0a86 --- /dev/null +++ b/platform/README.md @@ -0,0 +1,55 @@ +# New platform + +## Dev setup + +``` +npm run ts:start +``` + +This builds the code into `./ts-tmp/` for now. + +(It will most likely show a couple type problems while building, but the files will still be built.) + +## VSCode + +If you want to see what it looks like with fantastic editor support. + +``` +$ cat ~/dev/elastic/kibana/.vscode/settings.json +// Place your settings in this file to overwrite default and user settings. +{ + "typescript.tsdk": "./node_modules/typescript/lib", + "typescript.referencesCodeLens.enabled": true +} +``` + +## Running code + +(Make sure to build the code first, e.g. `npm run ts:build` or `npm run ts:start`) + +Just starts a server: + +``` +node scripts/platform.js +``` + +If you update `config/kibana.yml` to e.g. contain `pid.file: ./kibana.pid` +you'll also see it write the PID file. (You can do this while running and just +send a SIGHUP.) + +With failure: + +``` +node scripts/platform.js -c ./config/kibana.dev.yml --port "test" +``` + +## Running tests + +Run Jest: + +``` +node scripts/jest.js +``` + +(add `--watch` for re-running on change) + diff --git a/platform/app/__tests__/index.test.ts b/platform/app/__tests__/index.test.ts new file mode 100644 index 000000000000000..719729b5bb4617b --- /dev/null +++ b/platform/app/__tests__/index.test.ts @@ -0,0 +1,81 @@ +const configService = { + start: jest.fn(), + stop: jest.fn(), + reloadConfig: jest.fn(), + getConfig: jest.fn(() => []) +}; + +const ConfigService = jest.fn(() => configService); + +const server = { + start: jest.fn(), + stop: jest.fn() +}; +const Server = jest.fn(() => server); + +const loggerService = { + upgrade: jest.fn(), + stop: jest.fn() +}; + +const logger = { + get: jest.fn().mockReturnValue({ + info: jest.fn(), + error: jest.fn() + }) +}; + +jest.mock('../../config', () => ({ ConfigService })); +jest.mock('../../server', () => ({ Server })); +jest.mock('../../logger', () => ({ logger, loggerService })); + +import { App } from '../'; +import { Env } from '../../env'; + +test('starts services on "start"', () => { + const env = new Env('.'); + const argv = { foo: 'bar' }; + + const app = new App(argv, env); + + expect(configService.start).toHaveBeenCalledTimes(0); + expect(loggerService.upgrade).toHaveBeenCalledTimes(0); + expect(server.start).toHaveBeenCalledTimes(0); + + app.start(); + + expect(configService.start).toHaveBeenCalledTimes(1); + expect(loggerService.upgrade).toHaveBeenCalledTimes(1); + expect(server.start).toHaveBeenCalledTimes(1); +}); + +test('reloads config', () => { + const env = new Env('.'); + const argv = { foo: 'bar' }; + + const app = new App(argv, env); + + expect(configService.reloadConfig).toHaveBeenCalledTimes(0); + + app.reloadConfig(); + + expect(configService.reloadConfig).toHaveBeenCalledTimes(1); +}); + +test('stops services on "shutdown"', () => { + const env = new Env('.'); + const argv = { foo: 'bar' }; + + const app = new App(argv, env); + app.start(); + + expect(configService.stop).toHaveBeenCalledTimes(0); + expect(loggerService.stop).toHaveBeenCalledTimes(0); + expect(server.stop).toHaveBeenCalledTimes(0); + + app.shutdown(); + + expect(configService.stop).toHaveBeenCalledTimes(1); + expect(loggerService.stop).toHaveBeenCalledTimes(1); + expect(server.stop).toHaveBeenCalledTimes(1); +}); \ No newline at end of file diff --git a/platform/app/index.ts b/platform/app/index.ts new file mode 100644 index 000000000000000..f2ea1d82c3c2065 --- /dev/null +++ b/platform/app/index.ts @@ -0,0 +1,55 @@ +import { BehaviorSubject } from 'rxjs'; + +import { Server } from '../server'; +import { Env } from '../env'; +import { ConfigService } from '../config'; +import { loggerService, logger, LoggerConfig } from '../logger'; + +const log = logger.get('app'); + +// Top-level entry point to kick off the app + +export class App { + configService: ConfigService; + server?: Server; + + constructor( + argv: {[key: string]: any}, + private readonly env: Env + ) { + this.configService = new ConfigService(argv, env); + } + + start() { + this.configService.start(); + + // In general we should try to do as _little_ as possible before we + // configure the logger, so beware of adding features before we kick it + // off below. + + const config$ = this.configService.getConfig(); + + const loggingConfig$ = config$ + .map(config => new LoggerConfig(config.atPath('logging'))); + + loggerService.upgrade(loggingConfig$); + + log.info('starting the server'); + + this.server = new Server(this.env, config$); + this.server.start(); + } + + reloadConfig() { + this.configService.reloadConfig(); + } + + shutdown() { + log.info('commencing shutdown sequence :boom:'); + if (this.server !== undefined) { + this.server.stop(); + } + this.configService.stop(); + loggerService.stop(); + } +} \ No newline at end of file diff --git a/platform/cli/args.ts b/platform/cli/args.ts new file mode 100644 index 000000000000000..4e4c4f6bfec4dc6 --- /dev/null +++ b/platform/cli/args.ts @@ -0,0 +1,150 @@ +import { accessSync, constants as fsConstants } from 'fs'; +import { resolve } from 'path'; +import * as chalk from 'chalk'; +import { Options as ArgvOptions } from 'yargs'; + +const { R_OK } = fsConstants; + +export const usage = 'Usage: bin/kibana [options]'; +export const description = 'Kibana is an open source (Apache Licensed), browser-based analytics and search dashboard for Elasticsearch.'; +export const docs = 'Documentation: https://elastic.co/kibana'; + +export const options: { [key: string]: ArgvOptions } = { + config: { + alias: 'c', + description: 'Path to the config file, can be changed with the ' + + '`CONFIG_PATH` environment variable as well. ' + + 'Use multiple --config args to include multiple config files.', + type: 'string', + coerce: arg => { + if (typeof arg === 'string') { + return resolve(arg); + } + if (Array.isArray(arg)) { + return arg.map((file: any) => resolve(file)); + } + return arg; + }, + requiresArg: true + }, + elasticsearch: { + alias: 'e', + description: 'URI for Elasticsearch instance', + type: 'string', + requiresArg: true + }, + host: { + alias: 'H', + description: 'The host to bind to', + type: 'string', + requiresArg: true + }, + port: { + alias: 'p', + description: 'The port to bind Kibana to', + type: 'number', + requiresArg: true + }, + quiet: { + alias: 'q', + description: 'Prevent all logging except errors', + //conflicts: 'silent' + // conflicts: ['quiet', 'verbose'] + }, + silent: { + alias: 'Q', + description: 'Prevent all logging', + //conflicts: 'quiet' + // conflicts: ['silent', 'verbose'] + }, + verbose: { + description: 'Turns on verbose logging' + // conflicts: ['silent', 'quiet'] + }, + 'log-file': { + alias: 'l', + description: 'The file to log to', + type: 'string', + requiresArg: true + }, + 'plugin-dir': {}, + dev: { + description: 'Run the server with development mode defaults' + }, + ssl: { + description: 'Dev only. Specify --no-ssl to not run the dev server using HTTPS', + type: 'boolean', + default: true + }, + 'base-path': { + description: 'Dev only. Specify --no-base-path to not put a proxy with a random base path in front of the dev server', + type: 'boolean', + default: true + }, + watch: { + description: 'Dev only. Specify --no-watch to prevent automatic restarts of the server in dev mode', + type: 'boolean', + default: true + } +}; + +const fileExists = (configPath: string): boolean => { + try { + accessSync(configPath, R_OK); + return true; + } catch (e) { + return false; + } +}; + +function ensureConfigExists(path: string) { + if (!fileExists(path)) { + throw new Error(`Config file [${path}] does not exist`); + } +} + +function snakeToCamel(s: string) { + return s.replace(/(\-\w)/g, m => m[1].toUpperCase()); +} + +export const check = (options: { [key: string]: any }) => + (argv: { [key: string]: any }) => { + // throw Error here to show error message + + const config = argv.config; + + // ensure config file exists + if (typeof config === 'string') { + ensureConfigExists(config); + } + if (Array.isArray(config)) { + config.map(ensureConfigExists); + } + + if (argv.port !== undefined && isNaN(argv.port)) { + throw new Error(`[port] must be a number, but was a string`); + } + + // make sure only allowed options are specified + const yargsSpecialOptions = ['$0', '_', 'help', 'h', 'version', 'v']; + const allowedOptions = Object.keys(options).reduce( + (allowed, option) => + allowed + .add(option) + .add(snakeToCamel(option)) + .add(options[option].alias || option), + new Set(yargsSpecialOptions) + ); + const unrecognizedOptions = Object.keys(argv).filter( + arg => !allowedOptions.has(arg) + ); + + if (unrecognizedOptions.length) { + throw new Error( + `The following options were not recognized:\n` + + ` ${chalk.bold(JSON.stringify(unrecognizedOptions))}` + ); + } + + return true; + }; diff --git a/platform/cli/cli.ts b/platform/cli/cli.ts new file mode 100644 index 000000000000000..292eafbde3aca3f --- /dev/null +++ b/platform/cli/cli.ts @@ -0,0 +1,36 @@ +import yargs from 'yargs'; + +import * as args from './args'; +import { Env } from '../env'; +import { App } from '../app'; +import pkg from '../../package.json'; + +export const parseArgv = (argv: Array) => + yargs(argv) + .usage(args.usage + '\n\n' + args.description) + .version(pkg.version) + .help() + .showHelpOnFail(false, 'Specify --help for available options') + .alias('help', 'h') + .alias('version', 'v') + .options(args.options) + .epilogue(args.docs) + .check(args.check(args.options)) + .argv; + +const run = (argv: {[key: string]: any}) => { + if (argv.help) { + return; + } + + const env = Env.createDefault(); + + const app = new App(argv, env); + app.start(); + + process.on('SIGHUP', () => app.reloadConfig()); + process.on('SIGINT', () => app.shutdown()); + process.on('SIGTERM', () => app.shutdown()); +}; + +export default (argv: Array) => run(parseArgv(argv)); diff --git a/platform/cli/index.ts b/platform/cli/index.ts new file mode 100644 index 000000000000000..04bf53285ba6726 --- /dev/null +++ b/platform/cli/index.ts @@ -0,0 +1,3 @@ +import cli from './cli'; + +cli(process.argv.slice(2)); diff --git a/platform/config/ConfigService.ts b/platform/config/ConfigService.ts new file mode 100644 index 000000000000000..eeedb67a8ce0419 --- /dev/null +++ b/platform/config/ConfigService.ts @@ -0,0 +1,63 @@ +import { BehaviorSubject, Observable } from 'rxjs'; +import { isEqual } from 'lodash'; + +import { KibanaConfig } from './KibanaConfig'; +import { getRawConfig, applyArgv } from './readConfig'; +import { Env } from '../env'; +import { logger } from '../logger'; + +const log = logger.get('settings'); + +type RawConfig = { [key: string]: any }; + +export class ConfigService { + // We rely on a BehaviorSubject as we want every subscriber to immediately + // receive the current config when subscribing. + private readonly rawConfigFromFile$: BehaviorSubject = + new BehaviorSubject(undefined) + + private readonly config$: Observable; + + constructor( + private readonly argv: {[key: string]: any}, + private readonly env: Env + ) { + this.config$ = this.rawConfigFromFile$ + .filter(rawConfig => rawConfig !== undefined) + // we need to specify the type here, as we _know_ `RawConfig` no longer + // can be `undefined`. + .map(rawConfig => applyArgv(argv, rawConfig)) + // we only care about reloading the config if there are changes + .distinctUntilChanged((prev, next) => isEqual(prev, next)) + .map(config => KibanaConfig.create(config)); + } + + /** + * Reads the initial Kibana config + */ + start() { + this.loadConfig(); + } + + /** + * Re-reads the Kibana config + */ + reloadConfig() { + log.info('reloading config'); + this.loadConfig(); + log.info('reloading config done'); + } + + private loadConfig() { + const config = getRawConfig(this.argv.config, this.env.getDefaultConfigFile()); + this.rawConfigFromFile$.next(config); + } + + stop() { + this.rawConfigFromFile$.complete(); + } + + getConfig() { + return this.config$; + } +} \ No newline at end of file diff --git a/platform/config/KibanaConfig.ts b/platform/config/KibanaConfig.ts new file mode 100644 index 000000000000000..334f10fd0346595 --- /dev/null +++ b/platform/config/KibanaConfig.ts @@ -0,0 +1,20 @@ +import { schema } from './schema'; +import { TypeOf } from '../lib/schema'; + +import { isPlainObject } from 'lodash'; + +type ConfigType = TypeOf; + +export class KibanaConfig { + private constructor( + private readonly config: ConfigType + ) {} + + static create(val: T) { + return new KibanaConfig(schema.validate(val)); + } + + atPath(path: T): ConfigType[T] { + return this.config[path]; + } +} diff --git a/platform/config/index.ts b/platform/config/index.ts new file mode 100644 index 000000000000000..e20183b39c9198d --- /dev/null +++ b/platform/config/index.ts @@ -0,0 +1,2 @@ +export { ConfigService } from './ConfigService'; +export { KibanaConfig } from './KibanaConfig'; diff --git a/platform/config/mergeConfig.ts b/platform/config/mergeConfig.ts new file mode 100644 index 000000000000000..ca07f35e7e55866 --- /dev/null +++ b/platform/config/mergeConfig.ts @@ -0,0 +1,31 @@ +import { isPlainObject, forOwn, set, transform } from 'lodash'; + +// TODO Describe this, purely copied from old Kibana +export function mergeConfigs(sources: Array): { [key: string]: any } { + return transform( + sources, + (merged, source) => { + forOwn(source, function apply(val, key) { + if (key === undefined) { + return; + } + + if (isPlainObject(val)) { + forOwn(val, function (subVal, subKey) { + apply(subVal, key + '.' + subKey); + }); + return; + } + + if (Array.isArray(val)) { + set(merged, key, []); + val.forEach((subVal, i) => apply(subVal, key + '.' + i)); + return; + } + + set(merged, key, val); + }); + }, + {} + ); +} diff --git a/platform/config/readConfig.ts b/platform/config/readConfig.ts new file mode 100644 index 000000000000000..5eb183c11de2898 --- /dev/null +++ b/platform/config/readConfig.ts @@ -0,0 +1,73 @@ +import { has, set } from 'lodash'; +import { readFileSync } from 'fs'; +import { safeLoad } from 'js-yaml'; + +import { mergeConfigs } from './mergeConfig'; + +const readYaml = (path: string) => + safeLoad(readFileSync(path, 'utf8')); + +const readFiles = (paths: string[]) => + paths.map(readYaml); + +const toArray = (paths: string | string[]) => + typeof paths === 'string' ? [paths] : paths; + +/** + * Applies the values from argv by mutating the input config object. + */ +export function applyArgv( + argv: { [key: string]: any }, + config: { [key: string]: any } +) { + if (argv.dev) { + set(config, ['env'], 'development'); + + if (argv.ssl) { + set(config, ['server', 'ssl', 'enabled'], true); + + if ( + !has(config, ['server', 'ssl', 'certificate']) && + !has(config, ['server', 'ssl', 'key']) + ) { + // TODO fix contants + set(config, ['server', 'ssl', 'certificate'], 'DEV_SSL_CERT_PATH'); + set(config, ['server', 'ssl', 'key'], 'DEV_SSL_KEY_PATH'); + } + } + } + + if (argv.elasticsearch != null) { + set(config, ['elasticsearch', 'url'], argv.elasticsearch); + } + if (argv.port != null) { + set(config, ['server', 'port'], argv.port); + } + if (argv.host != null) { + set(config, ['server', 'host'], argv.host); + } + if (argv.quiet) { + set(config, ['logging', 'quiet'], true); + } + if (argv.silent) { + set(config, ['logging', 'silent'], true); + } + if (argv.verbose) { + set(config, ['logging', 'verbose'], true); + } + if (argv.logFile != null) { + set(config, ['logging', 'dest'], argv.logFile); + } + + return config; +} + +export function getRawConfig(configFiles: string | string[] | undefined, defaultConfigFile: string) { + const files = configFiles === undefined + ? [defaultConfigFile] + : toArray(configFiles); + + return files === undefined + ? {} + : mergeConfigs(readFiles(files));; +} diff --git a/platform/config/schema.ts b/platform/config/schema.ts new file mode 100644 index 000000000000000..e0768d3a0a40907 --- /dev/null +++ b/platform/config/schema.ts @@ -0,0 +1,22 @@ +import { object, maybe, string, boolean } from '../lib/schema'; +import { schema as logging } from '../logger'; +import { schema as server } from '../server/http'; +import { schema as pid } from '../server/pid'; + +export const schema = object({ + server, + logging, + pid: maybe(pid), + // this is only here to parse `./config/kibana.dev.yml` + optimize: maybe( + object({ + sourceMaps: string(), + unsafeCache: boolean({ + defaultValue: true + }), + lazyPrebuild: boolean({ + defaultValue: false + }) + }) + ) +}); diff --git a/platform/env/index.ts b/platform/env/index.ts new file mode 100644 index 000000000000000..553c72d4aa5be8f --- /dev/null +++ b/platform/env/index.ts @@ -0,0 +1,31 @@ +import * as process from 'process'; +import { resolve } from 'path'; + +export class Env { + + readonly configDir: string; + readonly pluginsDir: string; + readonly binDir: string; + readonly logDir: string; + readonly staticFilesDir: string; + + static createDefault(): Env { + return new Env(process.cwd()); + } + + constructor(readonly homeDir: string) { + this.configDir = resolve(this.homeDir, 'config'); + this.pluginsDir = resolve(this.homeDir, 'plugins'); + this.binDir = resolve(this.homeDir, 'bin'); + this.logDir = resolve(this.homeDir, 'log'); + this.staticFilesDir = resolve(this.homeDir, 'ui'); + } + + getDefaultConfigFile() { + return resolve(this.configDir, 'kibana.yml'); + } + + getPluginDir(pluginId: string): string { + return resolve(this.pluginsDir, pluginId); + } +} diff --git a/platform/integration_tests/__tests__/__snapshots__/cli.test.ts.snap b/platform/integration_tests/__tests__/__snapshots__/cli.test.ts.snap new file mode 100644 index 000000000000000..2e2e8e848b17037 --- /dev/null +++ b/platform/integration_tests/__tests__/__snapshots__/cli.test.ts.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`displays help 1`] = ` +"Usage: bin/kibana [options] + +Kibana is an open source (Apache Licensed), browser-based analytics and search +dashboard for Elasticsearch. + +Options: + --version, -v Show version number [boolean] + --help, -h Show help [boolean] + --config, -c Path to the config file, can be changed with the + \`CONFIG_PATH\` environment variable as well. Use multiple + --config args to include multiple config files. [string] + --elasticsearch, -e URI for Elasticsearch instance [string] + --host, -H The host to bind to [string] + --port, -p The port to bind Kibana to [number] + --quiet, -q Prevent all logging except errors + --silent, -Q Prevent all logging + --verbose Turns on verbose logging + --log-file, -l The file to log to [string] + --dev Run the server with development mode defaults + --ssl Dev only. Specify --no-ssl to not run the dev server + using HTTPS [boolean] [default: true] + --base-path Dev only. Specify --no-base-path to not put a proxy with + a random base path in front of the dev server + [boolean] [default: true] + --watch Dev only. Specify --no-watch to prevent automatic + restarts of the server in dev mode + [boolean] [default: true] + +Documentation: https://elastic.co/kibana + +" +`; diff --git a/platform/integration_tests/__tests__/cli.test.ts b/platform/integration_tests/__tests__/cli.test.ts new file mode 100644 index 000000000000000..2bcc31247bc4821 --- /dev/null +++ b/platform/integration_tests/__tests__/cli.test.ts @@ -0,0 +1,7 @@ +import { runCli } from '../runCli'; + +test('displays help', () => { + const result = runCli('.', ['--help']); + expect(result.status).toBe(0); + expect(result.stdout).toMatchSnapshot(); +}); diff --git a/platform/integration_tests/runCli.ts b/platform/integration_tests/runCli.ts new file mode 100644 index 000000000000000..b003e60cb1c9431 --- /dev/null +++ b/platform/integration_tests/runCli.ts @@ -0,0 +1,22 @@ +import { resolve } from 'path'; +import { spawnSync } from 'child_process'; + +const KIBANA_CLI_PATH = resolve(__dirname, '../../scripts/platform.js'); + +export function runCli(dir: string, args: string[] = []) { + const isRelative = dir[0] !== '/'; + + if (isRelative) { + dir = resolve(__dirname, dir); + } + + const result = spawnSync('node', [KIBANA_CLI_PATH, ...args], { + cwd: dir + }); + + return { + ...result, + stdout: result.stdout && result.stdout.toString(), + stderr: result.stderr && result.stderr.toString() + }; +}; diff --git a/platform/lib/ByteSizeValue/__tests__/index.test.ts b/platform/lib/ByteSizeValue/__tests__/index.test.ts new file mode 100644 index 000000000000000..d694dbff593c8a9 --- /dev/null +++ b/platform/lib/ByteSizeValue/__tests__/index.test.ts @@ -0,0 +1,95 @@ +import { ByteSizeValue } from '../'; + +describe('parsing units', () => { + test('bytes', () => { + expect(ByteSizeValue.parse('123b').getValueInBytes()).toBe(123); + }); + + test('kilobytes', () => { + expect(ByteSizeValue.parse('1kb').getValueInBytes()).toBe(1024); + expect(ByteSizeValue.parse('15kb').getValueInBytes()).toBe(15360); + }); + + test('megabytes', () => { + expect(ByteSizeValue.parse('1mb').getValueInBytes()).toBe(1048576); + }); + + test('gigabytes', () => { + expect(ByteSizeValue.parse('1gb').getValueInBytes()).toBe(1073741824); + }); + + test('throws an error when no unit specified', () => { + expect(() => ByteSizeValue.parse('123')).toThrowError( + 'could not parse byte size value' + ); + }); + + test('throws an error when unsupported unit specified', () => { + expect(() => ByteSizeValue.parse('1tb')).toThrowError( + 'could not parse byte size value' + ); + }); +}); + +describe('#isGreaterThan', () => { + test('handles true', () => { + const a = ByteSizeValue.parse('2kb'); + const b = ByteSizeValue.parse('1kb'); + expect(a.isGreaterThan(b)).toBe(true); + }); + + test('handles false', () => { + const a = ByteSizeValue.parse('2kb'); + const b = ByteSizeValue.parse('1kb'); + expect(b.isGreaterThan(a)).toBe(false); + }); +}); + +describe('#isLessThan', () => { + test('handles true', () => { + const a = ByteSizeValue.parse('2kb'); + const b = ByteSizeValue.parse('1kb'); + expect(b.isLessThan(a)).toBe(true); + }); + + test('handles false', () => { + const a = ByteSizeValue.parse('2kb'); + const b = ByteSizeValue.parse('1kb'); + expect(a.isLessThan(b)).toBe(false); + }); +}); + +describe('#isEqualTo', () => { + test('handles true', () => { + const a = ByteSizeValue.parse('1kb'); + const b = ByteSizeValue.parse('1kb'); + expect(b.isEqualTo(a)).toBe(true); + }); + + test('handles false', () => { + const a = ByteSizeValue.parse('2kb'); + const b = ByteSizeValue.parse('1kb'); + expect(a.isEqualTo(b)).toBe(false); + }); +}); + +describe('#toString', () => { + test('renders to nearest lower unit by default', () => { + expect(ByteSizeValue.parse('1b').toString()).toBe('1b'); + expect(ByteSizeValue.parse('10b').toString()).toBe('10b'); + expect(ByteSizeValue.parse('1023b').toString()).toBe('1023b'); + expect(ByteSizeValue.parse('1024b').toString()).toBe('1kb'); + expect(ByteSizeValue.parse('1025b').toString()).toBe('1kb'); + expect(ByteSizeValue.parse('1024kb').toString()).toBe('1mb'); + expect(ByteSizeValue.parse('1024mb').toString()).toBe('1gb'); + expect(ByteSizeValue.parse('1024gb').toString()).toBe('1024gb'); + }); + + test('renders to specified unit', () => { + expect(ByteSizeValue.parse('1024b').toString('b')).toBe('1024b'); + expect(ByteSizeValue.parse('1kb').toString('b')).toBe('1024b'); + expect(ByteSizeValue.parse('1mb').toString('kb')).toBe('1024kb'); + expect(ByteSizeValue.parse('1mb').toString('b')).toBe('1048576b'); + expect(ByteSizeValue.parse('512mb').toString('gb')).toBe('0.5gb'); + }); +}); diff --git a/platform/lib/ByteSizeValue/index.ts b/platform/lib/ByteSizeValue/index.ts new file mode 100644 index 000000000000000..a659482f90cec3b --- /dev/null +++ b/platform/lib/ByteSizeValue/index.ts @@ -0,0 +1,73 @@ +type ByteSizeValueUnit = 'b' | 'kb' | 'mb' | 'gb'; + +const unitMultiplier: {[unit: string]: number} = { + b: 1024 ** 0, + kb: 1024 ** 1, + mb: 1024 ** 2, + gb: 1024 ** 3 +}; + +function renderUnit(value: number, unit: string) { + const prettyValue = Number(value.toFixed(2)); + return `${prettyValue}${unit}`; +} + +export class ByteSizeValue { + + constructor( + private readonly valueInBytes: number + ) {} + + isGreaterThan(other: ByteSizeValue): boolean { + return this.valueInBytes > other.valueInBytes; + } + + isLessThan(other: ByteSizeValue): boolean { + return this.valueInBytes < other.valueInBytes; + } + + isEqualTo(other: ByteSizeValue): boolean { + return this.valueInBytes === other.valueInBytes; + } + + getValueInBytes(): number { + return this.valueInBytes; + } + + toString(returnUnit?: ByteSizeValueUnit) { + let value = this.valueInBytes; + let unit = `b`; + + for (const nextUnit of ['kb', 'mb', 'gb']) { + if (unit === returnUnit || (returnUnit == null && value < 1024)) { + return renderUnit(value, unit); + } + + value = value / 1024; + unit = nextUnit; + } + + return renderUnit(value, unit); + } + + static parse(text: string): ByteSizeValue { + const match = /([1-9][0-9]*)(b|kb|mb|gb)/.exec(text); + if (!match) { + throw new Error( + `could not parse byte size value [${text}]. value must start with a ` + + `number and end with bytes size unit, e.g. 10kb, 23mb, 3gb, 239493b` + ); + } + + const value = parseInt(match[1]); + const unit = match[2]; + + return new ByteSizeValue(value * unitMultiplier[unit]); + } +} + +export const bytes = (value: number) => new ByteSizeValue(value); +export const kb = (value: number) => bytes(value * 1024); +export const mb = (value: number) => kb(value * 1024); +export const gb = (value: number) => mb(value * 1024); +export const tb = (value: number) => gb(value * 1024); diff --git a/platform/lib/Config/index.ts b/platform/lib/Config/index.ts new file mode 100644 index 000000000000000..2251d71b6742ddd --- /dev/null +++ b/platform/lib/Config/index.ts @@ -0,0 +1,50 @@ +import { isPlainObject } from 'lodash'; + +// TODO Not used yet, just here to explore some ideas + +export class Config { + private constructor( + private readonly config: CFG + ) {} + + /** + * Retrieve a new Config object at the specified key + */ + configFor(key: A): Config { + return Config.create(this.config[key]); + } + + /** + * Retrieve the value for the specified path + * + * Note that dot is _not_ allowed to specify a deeper key, it will assume that + * the dot is part of the key itself. + */ + atPath(path: [A, B, C, D, E]): CFG[A][B][C][D][E]; + atPath(path: [A, B, C, D]): CFG[A][B][C][D]; + atPath(path: [A, B, C]): CFG[A][B][C]; + atPath(path: [A, B]): CFG[A][B]; + atPath(path: [A]): CFG[A]; + atPath(path: A): CFG[A]; + atPath(path: Array | string): any { + let obj = this.config; + + if (typeof path === 'string') { + return obj[path]; + } + + for (let key of path) { + obj = obj[key] + } + + return obj; + } + + static create(val: T) { + if (!isPlainObject(val)) { + throw new Error('nope'); + } + + return new Config(val); + } +} diff --git a/platform/lib/Duration/index.ts b/platform/lib/Duration/index.ts new file mode 100644 index 000000000000000..7e232e3559d3145 --- /dev/null +++ b/platform/lib/Duration/index.ts @@ -0,0 +1,49 @@ +const timeUnit = { + SECONDS: 1000, + MINUTES: 60 * 1000, + HOURS: 60 * 60 * 1000, + DAYS: 24 * 60 * 60 * 1000, + WEEKS: 7 * 24 * 60 * 60 * 1000 +}; + +const timeFormatRegex = /^(0|[1-9][0-9]*)(ms|s|m|h|d|w)$/gm; + +export const seconds = (value: number) => value * timeUnit.SECONDS; + +export const minutes = (value: number) => value * timeUnit.MINUTES; + +export const hours = (value: number) => value * timeUnit.HOURS; + +export const days = (value: number) => value * timeUnit.DAYS; + +export const weeks = (value: number) => value * timeUnit.WEEKS; + +export function parse(text: string): number { + const result = timeFormatRegex.exec(text); + if (!result) { + throw new Error( + `Failed to parse [${text}] as time value. Format must be [ms|s|m|h|d|w] (e.g. 300ms, 5s, 3d, etc...` + ); + } + + const count = parseInt(result[1]); + const unit = result[2]; + + switch (unit) { + case 'ms': + return count; + case 's': + return seconds(count); + case 'm': + return minutes(count); + case 'h': + return hours(count); + case 'd': + return days(count); + case 'w': + return weeks(count); + default: + // should never happen... if it does, we have a bug in the regexp above + throw new Error(`Failed to parse [${text}]. Unknown time unit [${unit}]`); + } +} diff --git a/platform/lib/Errors/index.ts b/platform/lib/Errors/index.ts new file mode 100644 index 000000000000000..8befbd8f63be3ab --- /dev/null +++ b/platform/lib/Errors/index.ts @@ -0,0 +1,18 @@ +export class KibanaError extends Error { + cause?: Error; + + constructor(message: string, cause?: Error) { + super(message); + this.cause = cause; + } + + toString(): string { + return `${this.message}\n${this.stack}`; + } +} + +export class TimeoutError extends KibanaError { + constructor(message: string) { + super(message); + } +} diff --git a/platform/lib/observable/index.ts b/platform/lib/observable/index.ts new file mode 100644 index 000000000000000..e14395be9601d74 --- /dev/null +++ b/platform/lib/observable/index.ts @@ -0,0 +1 @@ +export { destroyAndCreateOnNext } from './resourceLifecycle'; diff --git a/platform/lib/observable/resourceLifecycle.ts b/platform/lib/observable/resourceLifecycle.ts new file mode 100644 index 000000000000000..d5bd8be861b250b --- /dev/null +++ b/platform/lib/observable/resourceLifecycle.ts @@ -0,0 +1,42 @@ +import { Observable } from 'rxjs'; + +/** + * Synchronously recreate (`onDestroy`, then `onCreate`) a resource whenever + * `next` value is received. + * + * Also calls `onDestroy` on `error` and `complete`. + */ +export function destroyAndCreateOnNext( + source: Observable, + onCreate: (value: T) => R, + onDestroy: (value: R) => void +): Observable { + return new Observable(observer => { + let isCreated = false; + let currentValue: R; + + const ensureDestroyed = () => { + if (isCreated) { + onDestroy(currentValue); + isCreated = false; + } + } + + return source.subscribe( + value => { + ensureDestroyed(); + currentValue = onCreate(value); + isCreated = true; + observer.next(currentValue); + }, + err => { + ensureDestroyed(); + observer.error(err); + }, + () => { + ensureDestroyed(); + observer.complete(); + } + ); + }); +} diff --git a/platform/lib/schema/SettingError.ts b/platform/lib/schema/SettingError.ts new file mode 100644 index 000000000000000..b2901a900d7183b --- /dev/null +++ b/platform/lib/schema/SettingError.ts @@ -0,0 +1,22 @@ +import { KibanaError } from '../Errors'; + +export class SettingError extends KibanaError { + constructor(error: Error | string, key?: string) { + super( + SettingError.extractMessage(error, key), + SettingError.extractCause(error) + ); + } + + static extractMessage(error: Error | string, context?: string) { + const message = typeof error === 'string' ? error : error.message; + if (context == null) { + return `Invalid setting. ${message}`; + } + return `Invalid [${context}] setting. ${message}`; + } + + static extractCause(error: Error | string): Error | undefined { + return typeof error !== 'string' ? error : undefined; + } +} diff --git a/platform/lib/schema/__tests__/ArraySetting.test.ts b/platform/lib/schema/__tests__/ArraySetting.test.ts new file mode 100644 index 000000000000000..0e656f0f06be7e2 --- /dev/null +++ b/platform/lib/schema/__tests__/ArraySetting.test.ts @@ -0,0 +1,119 @@ +import { arrayOf, string, object, maybe } from '../'; + +test('returns value by default', () => { + const setting = arrayOf(string()); + expect(setting.validate(['foo', 'bar', 'baz'])).toEqual([ + 'foo', + 'bar', + 'baz' + ]); +}); + +test('fails if wrong input type', () => { + const setting = arrayOf(string()); + expect(() => setting.validate('test')).toThrowErrorMatchingSnapshot(); +}); + +test('fails if wrong type of content in array', () => { + const setting = arrayOf(string()); + expect(() => setting.validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); +}); + +test('fails if mixed types of content in array', () => { + const setting = arrayOf(string()); + expect(() => + setting.validate(['foo', 'bar', true, {}])).toThrowErrorMatchingSnapshot(); +}); + +test('returns empty array if input is empty but setting has default value', () => { + const setting = arrayOf(string({ defaultValue: 'test' })); + expect(setting.validate([])).toEqual([]); +}); + +test('returns empty array if input is empty even if setting is required', () => { + const setting = arrayOf(string()); + expect(setting.validate([])).toEqual([]); +}); + +test('fails for null values if optional', () => { + const setting = arrayOf(maybe(string())); + expect(() => setting.validate([null])).toThrowErrorMatchingSnapshot(); +}); + +test('handles default values for undefined values', () => { + const setting = arrayOf(string({ defaultValue: 'foo' })); + expect(setting.validate([undefined])).toEqual(['foo']); +}); + +test('array within array', () => { + const setting = arrayOf( + arrayOf(string(), { + minSize: 2, + maxSize: 2 + }), + { minSize: 1, maxSize: 1 } + ); + + const value = [['foo', 'bar']]; + + expect(setting.validate(value)).toEqual([['foo', 'bar']]); +}); + +test('object within array', () => { + const setting = arrayOf( + object({ + foo: string({ defaultValue: 'foo' }) + }) + ); + + const value = [ + { + foo: 'test' + } + ]; + + expect(setting.validate(value)).toEqual([{ foo: 'test' }]); +}); + +test('object within array with required', () => { + const setting = arrayOf( + object({ + foo: string() + }) + ); + + const value = [{}]; + + expect(() => setting.validate(value)).toThrowErrorMatchingSnapshot(); +}); + +describe('#minSize', () => { + test('returns value when more items', () => { + expect(arrayOf(string(), { minSize: 1 }).validate(['foo'])).toEqual([ + 'foo' + ]); + }); + + test('returns error when fewer items', () => { + expect(() => + arrayOf(string(), { minSize: 2 }).validate([ + 'foo' + ])).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#maxSize', () => { + test('returns value when fewer items', () => { + expect(arrayOf(string(), { maxSize: 2 }).validate(['foo'])).toEqual([ + 'foo' + ]); + }); + + test('returns error when more items', () => { + expect(() => + arrayOf(string(), { maxSize: 1 }).validate([ + 'foo', + 'bar' + ])).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/platform/lib/schema/__tests__/BooleanSetting.test.ts b/platform/lib/schema/__tests__/BooleanSetting.test.ts new file mode 100644 index 000000000000000..518b9290c0f5dd7 --- /dev/null +++ b/platform/lib/schema/__tests__/BooleanSetting.test.ts @@ -0,0 +1,27 @@ +import { boolean } from '../'; + +test('returns value by default', () => { + expect(boolean().validate(true)).toBe(true); +}); + +test('is required by default', () => { + expect(() => boolean().validate(undefined)).toThrowErrorMatchingSnapshot(); +}); + +describe('#defaultValue', () => { + test('returns default when undefined', () => { + expect(boolean({ defaultValue: true }).validate(undefined)).toBe(true); + }); + + test('returns value when specified', () => { + expect(boolean({ defaultValue: true }).validate(false)).toBe(false); + }); +}); + +test('returns error when not boolean', () => { + expect(() => boolean().validate(123)).toThrowErrorMatchingSnapshot(); + + expect(() => boolean().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => boolean().validate('abc')).toThrowErrorMatchingSnapshot(); +}); diff --git a/platform/lib/schema/__tests__/ByteSizeSetting.test.ts b/platform/lib/schema/__tests__/ByteSizeSetting.test.ts new file mode 100644 index 000000000000000..f4e4bd0440a5137 --- /dev/null +++ b/platform/lib/schema/__tests__/ByteSizeSetting.test.ts @@ -0,0 +1,65 @@ +import { byteSize } from '../'; +import { ByteSizeValue } from '../../../lib/ByteSizeValue'; + +test('returns value by default', () => { + expect(byteSize().validate('123b')).toMatchSnapshot(); +}); + +test('is required by default', () => { + expect(() => byteSize().validate(undefined)).toThrowErrorMatchingSnapshot(); +}); + +describe('#defaultValue', () => { + test('can be a ByteSizeValue', () => { + expect( + byteSize({ + defaultValue: ByteSizeValue.parse('1kb') + }).validate(undefined) + ).toMatchSnapshot(); + }); + + test('can be a string', () => { + expect( + byteSize({ + defaultValue: '1kb' + }).validate(undefined) + ).toMatchSnapshot(); + }); +}); + +describe('#min', () => { + test('returns value when larger', () => { + expect( + byteSize({ + min: '1b' + }).validate('1kb') + ).toMatchSnapshot(); + }); + + test('returns error when smaller', () => { + expect(() => + byteSize({ + min: '1kb' + }).validate('1b') + ).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#max', () => { + test('returns value when smaller', () => { + expect(byteSize({ max: '1kb' }).validate('1b')).toMatchSnapshot(); + }); + + test('returns error when larger', () => { + expect(() => + byteSize({ max: '1kb' }).validate('1mb')).toThrowErrorMatchingSnapshot(); + }); +}); + +test('returns error when not string', () => { + expect(() => byteSize().validate(123)).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate(/abc/)).toThrowErrorMatchingSnapshot(); +}); diff --git a/platform/lib/schema/__tests__/MaybeSetting.test.ts b/platform/lib/schema/__tests__/MaybeSetting.test.ts new file mode 100644 index 000000000000000..4e579058dc617f8 --- /dev/null +++ b/platform/lib/schema/__tests__/MaybeSetting.test.ts @@ -0,0 +1,16 @@ +import { maybe, string } from '../'; + +test('returns value if specified', () => { + const setting = maybe(string()); + expect(setting.validate('test')).toEqual('test'); +}); + +test('returns undefined if undefined', () => { + const setting = maybe(string()); + expect(setting.validate(undefined)).toEqual(undefined); +}); + +test('fails if null', () => { + const setting = maybe(string()); + expect(() => setting.validate(null)).toThrowErrorMatchingSnapshot(); +}); diff --git a/platform/lib/schema/__tests__/NumberSetting.test.ts b/platform/lib/schema/__tests__/NumberSetting.test.ts new file mode 100644 index 000000000000000..98795ce1cdc78ab --- /dev/null +++ b/platform/lib/schema/__tests__/NumberSetting.test.ts @@ -0,0 +1,51 @@ +import { number } from '../'; + +test('returns value by default', () => { + expect(number().validate(4)).toBe(4); +}); + +test('fails if number is `NaN`', () => { + expect(() => number().validate(NaN)).toThrowErrorMatchingSnapshot(); +}); + +test('is required by default', () => { + expect(() => number().validate(undefined)).toThrowErrorMatchingSnapshot(); +}); + +describe('#min', () => { + test('returns value when larger number', () => { + expect(number({ min: 2 }).validate(3)).toBe(3); + }); + + test('returns error when smaller number', () => { + expect(() => number({ min: 4 }).validate(3)).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#max', () => { + test('returns value when smaller number', () => { + expect(number({ max: 4 }).validate(3)).toBe(3); + }); + + test('returns error when larger number', () => { + expect(() => number({ max: 2 }).validate(3)).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#defaultValue', () => { + test('returns default when number is undefined', () => { + expect(number({ defaultValue: 2 }).validate(undefined)).toBe(2); + }); + + test('returns value when specified', () => { + expect(number({ defaultValue: 2 }).validate(3)).toBe(3); + }); +}); + +test('returns error when not number', () => { + expect(() => number().validate('test')).toThrowErrorMatchingSnapshot(); + + expect(() => number().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => number().validate(/abc/)).toThrowErrorMatchingSnapshot(); +}); diff --git a/platform/lib/schema/__tests__/ObjectSetting.test.ts b/platform/lib/schema/__tests__/ObjectSetting.test.ts new file mode 100644 index 000000000000000..e8975c2b9548ad4 --- /dev/null +++ b/platform/lib/schema/__tests__/ObjectSetting.test.ts @@ -0,0 +1,94 @@ +import { object, string } from '../'; + +test('returns value by default', () => { + const setting = object({ + name: string() + }); + const value = { + name: 'test' + }; + + expect(setting.validate(value)).toEqual({ name: 'test' }); +}); + +test('fails if missing string', () => { + const setting = object({ + name: string() + }); + const value = {}; + + expect(() => setting.validate(value)).toThrowErrorMatchingSnapshot(); +}); + +test('returns value if undefined string with default', () => { + const setting = object({ + name: string({ defaultValue: 'test' }) + }); + const value = {}; + + expect(setting.validate(value)).toEqual({ name: 'test' }); +}); + +test('fails if key does not exist in schema', () => { + const setting = object({ + foo: string() + }); + const value = { + bar: 'baz' + }; + + expect(() => setting.validate(value)).toThrowErrorMatchingSnapshot(); +}); + +test('object within object', () => { + const setting = object({ + foo: object({ + bar: string({ defaultValue: 'hello world' }) + }) + }); + const value = { foo: {} }; + + expect(setting.validate(value)).toEqual({ + foo: { + bar: 'hello world' + } + }); +}); + +test('object within object with required', () => { + const setting = object({ + foo: object({ + bar: string() + }) + }); + const value = {}; + + expect(() => setting.validate(value)).toThrowErrorMatchingSnapshot(); +}); + +describe('#validate', () => { + test('is called after all content is processed', () => { + let calledWith; + + const setting = object( + { + foo: object({ + bar: string({ defaultValue: 'baz' }) + }) + }, + { + validate: value => { + calledWith = value; + } + } + ); + + setting.validate({ foo: {} }); + + expect(calledWith).toEqual({ + foo: { + bar: 'baz' + } + }); + }); +}); diff --git a/platform/lib/schema/__tests__/StringSetting.test.ts b/platform/lib/schema/__tests__/StringSetting.test.ts new file mode 100644 index 000000000000000..7baae4f92b2c6e6 --- /dev/null +++ b/platform/lib/schema/__tests__/StringSetting.test.ts @@ -0,0 +1,82 @@ +import { string } from '../'; + +test('returns value is string and defined', () => { + expect(string().validate('test')).toBe('test'); +}); + +test('is required by default', () => { + expect(() => string().validate(undefined)).toThrowErrorMatchingSnapshot(); +}); + +describe('#minLength', () => { + test('returns value when longer string', () => { + expect(string({ minLength: 2 }).validate('foo')).toBe('foo'); + }); + + test('returns error when shorter string', () => { + expect(() => + string({ minLength: 4 }).validate('foo')).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#maxLength', () => { + test('returns value when shorter string', () => { + expect(string({ maxLength: 4 }).validate('foo')).toBe('foo'); + }); + + test('returns error when longer string', () => { + expect(() => + string({ maxLength: 2 }).validate('foo')).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#defaultValue', () => { + test('returns default when string is undefined', () => { + expect(string({ defaultValue: 'foo' }).validate(undefined)).toBe('foo'); + }); + + test('returns value when specified', () => { + expect(string({ defaultValue: 'foo' }).validate('bar')).toBe('bar'); + }); +}); + +describe('#validate', () => { + test('is called with input value', () => { + let calledWith; + + const validator = (val: any) => { + calledWith = val; + }; + + string({ validate: validator }).validate('test'); + + expect(calledWith).toBe('test'); + }); + + test('is called with default value in no input', () => { + let calledWith; + + const validate = (val: any) => { + calledWith = val; + }; + + string({ validate, defaultValue: 'foo' }).validate(undefined); + + expect(calledWith).toBe('foo'); + }); + + test('throws when returns string', () => { + const validate = () => 'validator failure'; + + expect(() => + string({ validate }).validate('foo')).toThrowErrorMatchingSnapshot(); + }); +}); + +test('returns error when not string', () => { + expect(() => string().validate(123)).toThrowErrorMatchingSnapshot(); + + expect(() => string().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => string().validate(/abc/)).toThrowErrorMatchingSnapshot(); +}); diff --git a/platform/lib/schema/__tests__/__snapshots__/ArraySetting.test.ts.snap b/platform/lib/schema/__tests__/__snapshots__/ArraySetting.test.ts.snap new file mode 100644 index 000000000000000..bb6d1b7fb5f85d7 --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/ArraySetting.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#maxSize returns error when more items 1`] = `"Invalid setting. array size is [2], but cannot be greater than [1]"`; + +exports[`#minSize returns error when fewer items 1`] = `"Invalid setting. array size is [1], but cannot be smaller than [2]"`; + +exports[`fails for null values if optional 1`] = `"Invalid [0] setting. expected value to either be undefined or defined, but not [null]"`; + +exports[`fails if mixed types of content in array 1`] = `"type_detect_1.default is not a function"`; + +exports[`fails if wrong input type 1`] = `"type_detect_1.default is not a function"`; + +exports[`fails if wrong type of content in array 1`] = `"type_detect_1.default is not a function"`; + +exports[`object within array with required 1`] = `"type_detect_1.default is not a function"`; diff --git a/platform/lib/schema/__tests__/__snapshots__/BooleanSetting.test.ts.snap b/platform/lib/schema/__tests__/__snapshots__/BooleanSetting.test.ts.snap new file mode 100644 index 000000000000000..c781e12fdd97802 --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/BooleanSetting.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`is required by default 1`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not boolean 1`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not boolean 2`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not boolean 3`] = `"type_detect_1.default is not a function"`; diff --git a/platform/lib/schema/__tests__/__snapshots__/ByteSizeSetting.test.ts.snap b/platform/lib/schema/__tests__/__snapshots__/ByteSizeSetting.test.ts.snap new file mode 100644 index 000000000000000..2c3ef427fc301ae --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/ByteSizeSetting.test.ts.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#defaultValue can be a ByteSizeValue 1`] = ` +ByteSizeValue { + "valueInBytes": 1024, +} +`; + +exports[`#defaultValue can be a string 1`] = ` +ByteSizeValue { + "valueInBytes": 1024, +} +`; + +exports[`#max returns error when larger 1`] = `"Invalid setting. Value is [1mb] ([1048576b]) but it must be equal to or less than [1kb]"`; + +exports[`#max returns value when smaller 1`] = ` +ByteSizeValue { + "valueInBytes": 1, +} +`; + +exports[`#min returns error when smaller 1`] = `"Invalid setting. Value is [1b] ([1b]) but it must be equal to or greater than [1kb]"`; + +exports[`#min returns value when larger 1`] = ` +ByteSizeValue { + "valueInBytes": 1024, +} +`; + +exports[`is required by default 1`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not string 1`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not string 2`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not string 3`] = `"type_detect_1.default is not a function"`; + +exports[`returns value by default 1`] = ` +ByteSizeValue { + "valueInBytes": 123, +} +`; diff --git a/platform/lib/schema/__tests__/__snapshots__/MaybeSetting.test.ts.snap b/platform/lib/schema/__tests__/__snapshots__/MaybeSetting.test.ts.snap new file mode 100644 index 000000000000000..61c372a8ba9b04a --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/MaybeSetting.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`fails if null 1`] = `"Invalid setting. expected value to either be undefined or defined, but not [null]"`; diff --git a/platform/lib/schema/__tests__/__snapshots__/NumberSetting.test.ts.snap b/platform/lib/schema/__tests__/__snapshots__/NumberSetting.test.ts.snap new file mode 100644 index 000000000000000..94bee2a67260c9a --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/NumberSetting.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#max returns error when larger number 1`] = `"Invalid setting. Value is [3] but it must be equal to or lower than [2]."`; + +exports[`#min returns error when smaller number 1`] = `"Invalid setting. Value is [3] but it must be equal to or greater than [4]."`; + +exports[`fails if number is \`NaN\` 1`] = `"type_detect_1.default is not a function"`; + +exports[`is required by default 1`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not number 1`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not number 2`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not number 3`] = `"type_detect_1.default is not a function"`; diff --git a/platform/lib/schema/__tests__/__snapshots__/ObjectSetting.test.ts.snap b/platform/lib/schema/__tests__/__snapshots__/ObjectSetting.test.ts.snap new file mode 100644 index 000000000000000..76f2aca8a45fba1 --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/ObjectSetting.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`fails if key does not exist in schema 1`] = `"Invalid setting. missing definitions in schema for keys [bar]"`; + +exports[`fails if missing string 1`] = `"type_detect_1.default is not a function"`; + +exports[`object within object with required 1`] = `"type_detect_1.default is not a function"`; diff --git a/platform/lib/schema/__tests__/__snapshots__/StringSetting.test.ts.snap b/platform/lib/schema/__tests__/__snapshots__/StringSetting.test.ts.snap new file mode 100644 index 000000000000000..127e4371a77b3cc --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/StringSetting.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#maxLength returns error when longer string 1`] = `"Invalid setting. value is [foo] but it must have a maximum length of [2]."`; + +exports[`#minLength returns error when shorter string 1`] = `"Invalid setting. value is [foo] but it must have a minimum length of [4]."`; + +exports[`#validate throws when returns string 1`] = `"Invalid setting. validator failure"`; + +exports[`is required by default 1`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not string 1`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not string 2`] = `"type_detect_1.default is not a function"`; + +exports[`returns error when not string 3`] = `"type_detect_1.default is not a function"`; diff --git a/platform/lib/schema/index.ts b/platform/lib/schema/index.ts new file mode 100644 index 000000000000000..4463802468d5dad --- /dev/null +++ b/platform/lib/schema/index.ts @@ -0,0 +1,356 @@ +import typeDetect from 'type-detect'; +import { difference, isPlainObject } from 'lodash'; + +import { SettingError } from './SettingError'; +import { ByteSizeValue } from '../ByteSizeValue'; + +function toContext(parent: string = '', child: string | number) { + return parent ? `${parent}.${child}` : String(child); +} + +type Any = Setting +export type TypeOf = RT['t']; + +type SettingOptions = { + defaultValue?: T, + validate?: (value: T) => string | void +}; + +const noop = () => {}; + +abstract class Setting { + readonly t: V; + private readonly _defaultValue: V | void; + private readonly _validate: (value: V) => string | void; + + constructor(options: SettingOptions = {}) { + this._defaultValue = options.defaultValue; + this._validate = options.validate || noop; + } + + validate(value: any = this._defaultValue, context?: string): V { + const result = this._process(value, context); + + const validation = this._validate(result); + if (typeof validation === 'string') { + throw new SettingError(validation, context); + } + + return result; + } + + abstract _process(value: any, context?: string): V; +} + +class MaybeSetting extends Setting { + private readonly setting: Setting; + + constructor(setting: Setting) { + super(); + this.setting = setting; + } + + _process(value: any, context?: string): V | undefined { + if (value === undefined) { + return value; + } + + if (value === null) { + throw new SettingError( + `expected value to either be undefined or defined, but not [null]`, + context + ) + } + + return this.setting._process(value, context); + } +} + +class BooleanSetting extends Setting { + _process(value: any, context?: string): boolean { + if (typeof value !== 'boolean') { + throw new SettingError( + `expected value of type [boolean] but got ${typeDetect(value)}`, + context + ); + } + + return value; + } +} + +type StringOptions = SettingOptions & { + minLength?: number, + maxLength?: number +}; + +class StringSetting extends Setting { + private readonly _minLength: number | void; + private readonly _maxLength: number | void; + + constructor(options: StringOptions = {}) { + super(options); + this._minLength = options.minLength; + this._maxLength = options.maxLength; + } + + _process(value: any, context?: string): string { + if (typeof value !== 'string') { + throw new SettingError( + `expected value of type [string] but got ${typeDetect(value)}`, + context + ); + } + + if (this._minLength && value.length < this._minLength) { + throw new SettingError( + `value is [${value}] but it must have a minimum length of [${this._minLength}].`, + context + ); + } + + if (this._maxLength && value.length > this._maxLength) { + throw new SettingError( + `value is [${value}] but it must have a maximum length of [${this._maxLength}].`, + context + ); + } + + return value; + } +} + +type NumberOptions = SettingOptions & { + min?: number, + max?: number +}; + +class NumberSetting extends Setting { + private readonly _min: number | void; + private readonly _max: number | void; + + constructor(options: NumberOptions = {}) { + super(options); + this._min = options.min; + this._max = options.max; + } + + _process(value: any, context?: string): number { + if (typeof value !== 'number' || isNaN(value)) { + throw new SettingError( + `expected value of type [number] but got [${typeDetect(value)}]`, + context + ); + } + + if (this._min && value < this._min) { + throw new SettingError( + `Value is [${value}] but it must be equal to or greater than [${this._min}].`, + context + ); + } + + if (this._max && value > this._max) { + throw new SettingError( + `Value is [${value}] but it must be equal to or lower than [${this._max}].`, + context + ); + } + + return value; + } +} + +type ByteSizeOptions = { + // we need to special-case defaultValue as we want to handle string inputs too + validate?: (value: ByteSizeValue) => string | void + defaultValue?: ByteSizeValue | string, + min?: ByteSizeValue | string, + max?: ByteSizeValue | string +}; + +function ensureByteSizeValue(value?: ByteSizeValue | string) { + return typeof value === 'string' ? ByteSizeValue.parse(value) : value; +} + +class ByteSizeSetting extends Setting { + private readonly _min: ByteSizeValue | void; + private readonly _max: ByteSizeValue | void; + + constructor(options: ByteSizeOptions = {}) { + const { defaultValue, min, max, ...rest } = options; + + super({ + ...rest, + defaultValue: ensureByteSizeValue(defaultValue) + }); + + this._min = ensureByteSizeValue(min); + this._max = ensureByteSizeValue(max); + } + + _process(value: any, context?: string): ByteSizeValue { + if (typeof value === 'string') { + value = ByteSizeValue.parse(value); + } + + if (!(value instanceof ByteSizeValue)) { + throw new SettingError( + `expected value of type [ByteSize] but got ${typeDetect(value)}`, + context + ); + } + + const { _min, _max } = this; + + if (_min && value.isLessThan(_min)) { + throw new SettingError( + `Value is [${value.toString()}] ([${value.toString('b')}]) but it must be equal to or greater than [${_min.toString()}]`, + context + ); + } + + if (_max && value.isGreaterThan(_max)) { + throw new SettingError( + `Value is [${value.toString()}] ([${value.toString('b')}]) but it must be equal to or less than [${_max.toString()}]`, + context + ); + } + + return value; + } +} + +type ArrayOptions = SettingOptions> & { + minSize?: number, + maxSize?: number +}; + +class ArraySetting extends Setting> { + private readonly _itemSetting: Setting; + private readonly _minSize: number | void; + private readonly _maxSize: number | void; + + constructor(setting: Setting, options: ArrayOptions = {}) { + super(options); + this._itemSetting = setting; + this._minSize = options.minSize; + this._maxSize = options.maxSize; + } + + _process(value: any, context?: string): Array { + if (!Array.isArray(value)) { + throw new SettingError( + `expected value of type [array] but got ${typeDetect(value)}`, + context + ); + } + + if (this._minSize != null && value.length < this._minSize) { + throw new SettingError( + `array size is [${value.length}], but cannot be smaller than [${this._minSize}]`, + context + ); + } + + if (this._maxSize != null && value.length > this._maxSize) { + throw new SettingError( + `array size is [${value.length}], but cannot be greater than [${this._maxSize}]`, + context + ); + } + + return value.map((val, i) => + this._itemSetting.validate(val, toContext(context, i))); + } +} + +type Props = { [key: string]: Any }; + +// Because of https://github.com/Microsoft/TypeScript/issues/14041 +// this might not have perfect _rendering_ output, but it will be typed. + +type ObjectSettingType

= Readonly<{ [K in keyof P]: TypeOf }> + +class ObjectSetting

extends Setting> { + private readonly schema: P; + + constructor( + schema: P, + options: SettingOptions<{ [K in keyof P]: TypeOf }> = {} + ) { + super({ + ...options, + defaultValue: options.defaultValue + }); + this.schema = schema; + } + + _process(value: any = {}, context?: string): ObjectSettingType

{ + if (!isPlainObject(value)) { + throw new SettingError( + `expected a plain object value, but found [${typeDetect(value)}] instead.`, + context + ); + } + + const schemaKeys = Object.keys(this.schema); + const valueKeys = Object.keys(value); + + // Do we have keys that exist in the values, but not in the schema? + const missingInSchema = difference(valueKeys, schemaKeys); + + if (missingInSchema.length > 0) { + throw new SettingError( + `missing definitions in schema for keys [${missingInSchema.join(',')}]`, + context + ); + } + + return schemaKeys.reduce( + (newObject: any, key) => { + const setting = this.schema[key]; + if (setting === undefined) { + console.log('undefined', toContext(context, key)) + } + newObject[key] = setting.validate(value[key], toContext(context, key)); + return newObject; + }, + {} + ); + } +} + +export function boolean(options?: SettingOptions): Setting { + return new BooleanSetting(options); +} + +export function string(options?: StringOptions): Setting { + return new StringSetting(options); +} + +export function number(options?: NumberOptions): Setting { + return new NumberSetting(options); +} + +export function byteSize(options?: ByteSizeOptions): Setting { + return new ByteSizeSetting(options); +} + +export function maybe(setting: Setting): MaybeSetting { + return new MaybeSetting(setting); +} + +export function object

( + schema: P, + options?: SettingOptions<{ [K in keyof P]: TypeOf }> +): ObjectSetting

{ + return new ObjectSetting(schema, options); +} + +export function arrayOf( + itemSetting: Setting, + options?: ArrayOptions +): Setting> { + return new ArraySetting(itemSetting, options); +} diff --git a/platform/lib/uuid/index.ts b/platform/lib/uuid/index.ts new file mode 100644 index 000000000000000..f0bb276c68a7c40 --- /dev/null +++ b/platform/lib/uuid/index.ts @@ -0,0 +1,3 @@ +const uuidV4Regex = /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; + +export const isValid = (uuid: string) => uuidV4Regex.test(uuid); diff --git a/platform/logger/EventLogger.ts b/platform/logger/EventLogger.ts new file mode 100644 index 000000000000000..44e23effd3b9423 --- /dev/null +++ b/platform/logger/EventLogger.ts @@ -0,0 +1,39 @@ +import { Level } from './Level'; +import { LoggerConfig } from './LoggerConfig'; + +export interface LogEvent { + error?: Error, + timestamp: string, + level: Level, + context: string[], + message: string, + meta?: { [name: string]: any } +} + +export interface EventLogger { + log(event: LogEvent): void; + update(config: LoggerConfig): void; + close(): void; +} + +export function createLoggerFromConfig(config: LoggerConfig): EventLogger { + return new ConsoleLogger(); +} + +class ConsoleLogger implements EventLogger { + log(event: LogEvent): void { + console.log( + `[${event.level.id}]`, + `[${event.context.join('.')}]`, + event.message + ); + } + + update(config: LoggerConfig): void { + console.log('update logger', config); + } + + close(): void { + console.log('close logger'); + } +} diff --git a/platform/logger/Level.ts b/platform/logger/Level.ts new file mode 100644 index 000000000000000..f7df686a8f43e90 --- /dev/null +++ b/platform/logger/Level.ts @@ -0,0 +1,21 @@ +import { LoggingError } from './LoggingError'; + +export class Level { + + static Fatal = new Level('fatal', 1, 'red'); + static Error = new Level('error', 2, 'red'); + static Warn = new Level('warn', 3, 'yellow'); + static Info = new Level('info', 4); + static Debug = new Level('debug', 5, 'green'); + static Trace = new Level('trace', 6, 'blue'); + + constructor( + readonly id: string, + readonly value: number, + readonly color?: string + ) {} + + supports(level: Level) { + return this.value >= level.value; + } +} diff --git a/platform/logger/LoggerAdapter.ts b/platform/logger/LoggerAdapter.ts new file mode 100644 index 000000000000000..a5291b842538a32 --- /dev/null +++ b/platform/logger/LoggerAdapter.ts @@ -0,0 +1,72 @@ +import { EventLogger } from './EventLogger'; +import { Level } from './Level'; + +// This is the logger interface that will be available. + +export interface Logger { + trace(message: string, meta?: {[key: string]: any }): void; + debug(message: string, meta?: {[key: string]: any }): void; + info(message: string, meta?: {[key: string]: any }): void; + warn(errorOrMessage: string | Error, meta?: {[key: string]: any }): void; + error(errorOrMessage: string | Error, meta?: {[key: string]: any }): void; + fatal(errorOrMessage: string | Error, meta?: {[key: string]: any }): void; +} + +export class LoggerAdapter implements Logger { + constructor( + private readonly namespace: string[], + private logger?: EventLogger, + private level?: Level + ) {} + + update(logger: EventLogger, level: Level): void { + this.logger = logger; + this.level = level; + } + + trace(message: string, meta?: {[key: string]: any }): void { + this.log(Level.Trace, message, meta); + } + + debug(message: string, meta?: {[key: string]: any }): void { + this.log(Level.Debug, message, meta); + } + + info(message: string, meta?: {[key: string]: any }): void { + this.log(Level.Info, message, meta); + } + + warn(errorOrMessage: string | Error, meta?: {[key: string]: any }): void { + this.log(Level.Warn, errorOrMessage, meta); + } + + error(errorOrMessage: string | Error, meta?: {[key: string]: any }): void { + this.log(Level.Error, errorOrMessage, meta); + } + + fatal(errorOrMessage: string | Error, meta?: {[key: string]: any }): void { + this.log(Level.Fatal, errorOrMessage, meta); + } + + private log(level: Level, errorOrMessage: string | Error, meta?: {[key: string]: any }): void { + if (this.logger === undefined || this.level === undefined) { + throw new Error(`Both logger and level must be specified. Logger was [${this.logger}]. Log level was [${this.level}].`); + } + + if (!this.level.supports(level)) { + return; + } + + const context = this.namespace; + const timestamp = new Date().toISOString(); + + if (errorOrMessage instanceof Error) { + const message = errorOrMessage.message; + const error = errorOrMessage; + this.logger.log({ timestamp, level, context, message, error, meta }); + } else { + const message = errorOrMessage; + this.logger.log({ timestamp, level, context, message, meta }); + } + } +} diff --git a/platform/logger/LoggerConfig.ts b/platform/logger/LoggerConfig.ts new file mode 100644 index 000000000000000..7770b1dad613794 --- /dev/null +++ b/platform/logger/LoggerConfig.ts @@ -0,0 +1,51 @@ +import { Level } from './Level'; +import { object, boolean, string, TypeOf } from '../lib/schema'; + +export const schema = object({ + dest: string({ + defaultValue: 'stdout' + }), + silent: boolean({ + defaultValue: false + }), + quiet: boolean({ + defaultValue: false + }), + verbose: boolean({ + defaultValue: false + }) +}); + +export type LoggingSchema = TypeOf; + +export class LoggerConfig { + readonly dest: string; + private readonly silent: boolean; + private readonly quiet: boolean; + private readonly verbose: boolean; + + constructor(config: LoggingSchema) { + this.dest = config.dest; + + // TODO: Feels like we should clean these up and move to + // specifying a `level` instead. + // To enable more control it's also possible to do a: + // ``` + // logging: { + // levels: { + // "default": "info", + // "requests": "error", + // "plugin.myPlugin": "trace" + // } + // } + // ``` + // and then log based on the `namespace`. + this.silent = config.silent; + this.quiet = config.quiet; + this.verbose = config.verbose; + } + + getLevel(): Level { + return Level.Debug; + } +} \ No newline at end of file diff --git a/platform/logger/LoggerFactory.ts b/platform/logger/LoggerFactory.ts new file mode 100644 index 000000000000000..e03dc709fb992f9 --- /dev/null +++ b/platform/logger/LoggerFactory.ts @@ -0,0 +1,79 @@ +import { Level } from './Level'; +import { LoggerConfig } from './LoggerConfig'; +import { Logger, LoggerAdapter } from './LoggerAdapter'; +import { createLoggerFromConfig, EventLogger } from './EventLogger'; + +export interface LoggerFactory { + get(...namespace: string[]): Logger; +} + +export interface MutableLogger { + updateLogger(logger: LoggerConfig): void; + close(): void; +} + +// # Mutable Logger Factory +// +// Performs two tasks: +// +// 1. Holds on to (and updates) the currently active `EventLogger`, aka the +// implementation that receives log events and performs the logging (e.g. +// Bunyan or Winston.) +// 2. Creates namespaced `LoggerAdapter`s (the log interface used in the app) +// and triggers updates on them whenever a new `LoggerConfig` is received. +// +// This `LoggerFactory` needs to be mutable as it's a singleton in the app, so +// it can be `import`-ed anywhere in the app instead of being injected everywhere. + +export class MutableLoggerFactory implements MutableLogger, LoggerFactory { + + // TODO Apply defaults here, so we always start with some logger defined + private logger?: EventLogger; + private level?: Level; + + // The cache of namespaced `LoggerAdapter`s + private readonly loggerByContext: { + [namespace: string]: LoggerAdapter + } = {}; + + get(...namespace: string[]): Logger { + const context = namespace.join('.'); + + if (this.loggerByContext[context] === undefined) { + this.loggerByContext[context] = new LoggerAdapter(namespace, this.logger, this.level); + } + + return this.loggerByContext[context]; + } + + updateLogger(config: LoggerConfig) { + const logger = this.createOrUpdateLogger(config) + const level = config.getLevel(); + + this.level = level; + this.logger = logger; + + Object.values(this.loggerByContext).forEach(loggerAdapter => { + loggerAdapter.update(logger, level); + }) + } + + private createOrUpdateLogger(config: LoggerConfig) { + if (this.logger === undefined) { + return createLoggerFromConfig(config); + } + + this.logger.update(config); + return this.logger; + } + + close(): void { + for (const key in this.loggerByContext) { + delete this.loggerByContext[key]; + } + + if (this.logger !== undefined) { + this.logger.close(); + } + } +} \ No newline at end of file diff --git a/platform/logger/LoggerService.ts b/platform/logger/LoggerService.ts new file mode 100644 index 000000000000000..ea01d34a239e51e --- /dev/null +++ b/platform/logger/LoggerService.ts @@ -0,0 +1,39 @@ +import { isEqual } from 'lodash'; +import { Observable, Subject } from 'rxjs'; + +import { MutableLogger } from './LoggerFactory'; +import { LoggerConfig } from './LoggerConfig'; + +// The `LoggerManager` is responsible for maintaining the log config +// subscription and pushing updates the the mutable logger. + +export class LoggerService { + mutableLogger: MutableLogger; + stop$ = new Subject(); + + constructor(mutableLogger: MutableLogger) { + this.mutableLogger = mutableLogger; + } + + upgrade(config$: Observable) { + config$ + .takeUntil(this.stop$) + .distinctUntilChanged((prevConfig, nextConfig) => + // TODO: Move equal check into class + isEqual(prevConfig, nextConfig) + ) + .subscribe({ + next: config => { + this.mutableLogger.updateLogger(config); + }, + complete: () => { + this.mutableLogger.close(); + } + }); + } + + stop() { + this.stop$.next(true); + this.stop$.complete(); + } +} \ No newline at end of file diff --git a/platform/logger/LoggingError.ts b/platform/logger/LoggingError.ts new file mode 100644 index 000000000000000..6c4cf62d54804e0 --- /dev/null +++ b/platform/logger/LoggingError.ts @@ -0,0 +1,7 @@ +import { KibanaError } from "../lib/Errors"; + +export class LoggingError extends KibanaError { + constructor(message: string, cause?: Error) { + super(message, cause); + } +} diff --git a/platform/logger/index.ts b/platform/logger/index.ts new file mode 100644 index 000000000000000..44c2694fa81fd97 --- /dev/null +++ b/platform/logger/index.ts @@ -0,0 +1,22 @@ +import { LoggerAdapter } from './LoggerAdapter'; +import { LoggerService } from './LoggerService'; +import { LoggerFactory, MutableLoggerFactory } from './LoggerFactory'; + +export { LoggerConfig, schema } from './LoggerConfig'; + +// # Logging +// +// This is a wrapper around external or internal log event systems, built as +// a mutable singleton. That way the logger can be required from any file, +// instead of having to inject it as a dependency into all services. +// +// The `LoggerService` is used to manage the log setup, while the +// `LoggerFactory` interface helps limit the external api to _only_ what's +// needed when used. +// +// TODO: For test setup, https://facebook.github.io/jest/docs/manual-mocks.html + +const loggerFactory = new MutableLoggerFactory(); + +export const loggerService = new LoggerService(loggerFactory); +export const logger: LoggerFactory = loggerFactory; diff --git a/platform/server/Service.ts b/platform/server/Service.ts new file mode 100644 index 000000000000000..2c3b0f8dfbe738a --- /dev/null +++ b/platform/server/Service.ts @@ -0,0 +1,4 @@ +export interface Service { + start(): void; + stop(): void; +} diff --git a/platform/server/http/HttpConfig.ts b/platform/server/http/HttpConfig.ts new file mode 100644 index 000000000000000..488b83f039d3c00 --- /dev/null +++ b/platform/server/http/HttpConfig.ts @@ -0,0 +1,56 @@ +import { sslSchema, SslConfig } from './SslConfig'; +import { Env } from '../../env'; +import { ByteSizeValue } from '../../lib/ByteSizeValue'; +import { + object, + maybe, + string, + number, + byteSize, + TypeOf +} from '../../lib/schema'; + +const hostnameRegex = /^(([A-Z0-9]|[A-Z0-9][A-Z0-9\-]*[A-Z0-9])\.)*([A-Z0-9]|[A-Z0-9][A-Z0-9\-]*[A-Z0-9])$/i; + +const match = (regex: RegExp, errorMsg: string) => + (str: string) => regex.test(str) ? undefined : errorMsg; + +export const schema = object({ + host: string({ + defaultValue: 'localhost', + validate: match(hostnameRegex, 'must be a valid hostname') + }), + port: number({ + defaultValue: 5601 + }), + maxPayload: byteSize({ + defaultValue: '1mb' + }), + basePath: maybe(string({ + validate: match( + /(^$|^\/.*[^\/]$)/, + 'must start with a slash, don\'t end with one' + ) + })), + ssl: sslSchema +}); + +export type HttpSchema = TypeOf; + +export class HttpConfig { + host: string; + port: number; + maxPayload: ByteSizeValue; + basePath?: string; + publicDir: string; + ssl: SslConfig; + + constructor(config: HttpSchema, env: Env) { + this.host = config.host; + this.port = config.port; + this.maxPayload = config.maxPayload; + this.basePath = config.basePath; + this.publicDir = env.staticFilesDir; + this.ssl = new SslConfig(config.ssl); + } +} diff --git a/platform/server/http/HttpService.ts b/platform/server/http/HttpService.ts new file mode 100644 index 000000000000000..85fc776209384ed --- /dev/null +++ b/platform/server/http/HttpService.ts @@ -0,0 +1,73 @@ +import * as http from 'http'; +import express from 'express'; +import { Observable, Subject } from 'rxjs'; + +import { HttpConfig } from './HttpConfig'; +import { Service } from '../Service'; +import { logger } from '../../logger'; +import { destroyAndCreateOnNext } from '../../lib/observable'; + +const log = logger.get('http'); + +export class HttpService implements Service { + private readonly app: express.Application; + private readonly httpServer: http.Server; + private readonly stop$ = new Subject(); + + constructor(private readonly config$: Observable) { + this.app = express(); + this.httpServer = http.createServer(this.app); + } + + start() { + this.config$ + .takeUntil(this.stop$) + // TODO Add an `equals` check to the http config + // .distinctUntilChanged((prev, next) => false) + .filter(config => { + if (this.httpServer.listening) { + // If the server is already running we can't make any config changes + // to it, so we warn and don't allow the config to pass through. + log.warn( + 'Received new HTTP config after server was started. ' + + 'Config will **not** be applied.' + ) + return false; + } + + return true; + }) + .let(observable => + destroyAndCreateOnNext( + observable, + config => this.startHttpServer(config), + () => this.stopHttpServer() + ) + ) + .subscribe() + } + + private startHttpServer(config: HttpConfig) { + const { host, port } = config; + + log.debug('bootstrapping http server'); + + this.httpServer.listen(port, host, (err: Error) => { + if (err != null) { + throw err; + } else { + log.info(`listening to [${host}:${port}]`, { host, port }) + } + }); + } + + private stopHttpServer() { + log.debug(`closing http server`); + this.httpServer.close(); + } + + stop(): void { + this.stop$.next(true); + this.stop$.complete(); + } +} \ No newline at end of file diff --git a/platform/server/http/SslConfig.ts b/platform/server/http/SslConfig.ts new file mode 100644 index 000000000000000..1af3da13f5a4471 --- /dev/null +++ b/platform/server/http/SslConfig.ts @@ -0,0 +1,55 @@ +import * as crypto from 'crypto'; +import { has } from 'lodash'; + +import { ByteSizeValue } from '../../lib/ByteSizeValue'; +import { + object, + maybe, + string, + number, + boolean, + arrayOf, + byteSize, + TypeOf +} from '../../lib/schema'; + +export const sslSchema = object({ + enabled: boolean({ + defaultValue: false + }), + certificate: maybe(string()), + key: maybe(string()), + keyPassphrase: maybe(string()), + certificateAuthorities: maybe(arrayOf(string())), + supportedProtocols: maybe( + arrayOf( + string({ + validate: protocol => + ['TLSv1', 'TLSv1.1', 'TLSv1.2'].includes(protocol) + ? undefined + : `protocol [${protocol}] is not allowed` + }) + ) + ), + cipherSuites: arrayOf(string(), { + // $ExpextError: 'constants' is currently missing in built-in types + defaultValue: crypto.constants.defaultCoreCipherList.split(':') + }) +}, +{ + validate: ssl => { + if (ssl.enabled && (!has(ssl, 'certificate') || !has(ssl, 'key'))) { + return 'must specify [certificate] and [key] when ssl is enabled'; + } + } +}) + +type SslSchema = TypeOf; + +export class SslConfig { + enabled: boolean; + + constructor(config: SslSchema) { + this.enabled = config.enabled; + } +} \ No newline at end of file diff --git a/platform/server/http/index.ts b/platform/server/http/index.ts new file mode 100644 index 000000000000000..ac7549084002e51 --- /dev/null +++ b/platform/server/http/index.ts @@ -0,0 +1,2 @@ +export { HttpConfig, schema } from './HttpConfig'; +export { HttpService } from './HttpService'; diff --git a/platform/server/index.ts b/platform/server/index.ts new file mode 100644 index 000000000000000..3dfb03dec1c26b0 --- /dev/null +++ b/platform/server/index.ts @@ -0,0 +1,43 @@ +import { Observable, Subscription } from 'rxjs'; + +import { HttpService, HttpConfig } from './http'; +import { PidConfig, PidService } from './pid'; +import { Env } from '../env'; +import { logger } from '../logger'; +import { ConfigService, KibanaConfig } from '../config'; + +const log = logger.get('server'); + +export class Server { + private readonly http: HttpService; + private readonly pid: PidService; + + constructor( + private readonly env: Env, + config$: Observable + ) { + const httpConfig$ = config$ + .map(config => new HttpConfig(config.atPath('server'), env)); + + const pidConfig$ = config$ + .map(config => config.atPath('pid')) + .map(pidConfig => + pidConfig !== undefined + ? new PidConfig(pidConfig) + : undefined + ); + + this.pid = new PidService(pidConfig$); + this.http = new HttpService(httpConfig$); + } + + start() { + this.pid.start(); + this.http.start(); + } + + stop() { + this.http.stop(); + this.pid.stop(); + } +} diff --git a/platform/server/pid/PidConfig.ts b/platform/server/pid/PidConfig.ts new file mode 100644 index 000000000000000..3af26d1d65762f1 --- /dev/null +++ b/platform/server/pid/PidConfig.ts @@ -0,0 +1,22 @@ +import { object, maybe, string, boolean, TypeOf } from '../../lib/schema'; + +export const schema = object({ + file: string(), + + // whether or not we should fail if pid file already exists + exclusive: boolean({ + defaultValue: false + }) +}); + +export type PidSchema = TypeOf; + +export class PidConfig { + file: string; + exclusive: boolean; + + constructor(config: PidSchema) { + this.file = config.file; + this.exclusive = config.exclusive; + } +} \ No newline at end of file diff --git a/platform/server/pid/PidService.ts b/platform/server/pid/PidService.ts new file mode 100644 index 000000000000000..991235c87d61dea --- /dev/null +++ b/platform/server/pid/PidService.ts @@ -0,0 +1,94 @@ +import { writeFileSync, unlinkSync } from 'fs'; +import { Observable, Subject } from 'rxjs'; + +import { PidConfig } from './PidConfig'; +import { Service } from '../Service'; +import { logger } from '../../logger'; +import { KibanaError } from '../../lib/Errors'; +import { destroyAndCreateOnNext } from '../../lib/observable'; + +const FILE_ALREADY_EXISTS = 'EEXIST'; + +const log = logger.get('server', 'pid'); + +class PidFile { + constructor( + private readonly pid: number, + private readonly pidConfig: PidConfig + ) {} + + writeFile(): this { + const pid = String(this.pid); + const path = this.pidConfig.file; + + try { + writeFileSync(path, pid, { flag: 'wx' }); + } catch (err) { + if (err.code !== FILE_ALREADY_EXISTS) { + throw err; + } + + const message = `pid file already exists at [${path}]`; + + if (this.pidConfig.exclusive) { + throw new KibanaError(message, err); + } + + log.warn(message, { path, pid }); + + writeFileSync(path, pid); + } + + log.debug(`wrote pid file [${path}]`); + + return this; + } + + deleteFile(): this { + const path = this.pidConfig.file; + log.debug(`deleting pid file [${path}]`); + unlinkSync(path); + + return this; + } +} + +export class PidService implements Service { + private readonly stop$ = new Subject(); + + constructor( + private readonly pidConfig$: Observable + ) {} + + start() { + this.pidConfig$ + .takeUntil(this.stop$) + // We only want to make changes whenever the config changes + .distinctUntilChanged((prevConfig, nextConfig) => { + if (prevConfig !== undefined && nextConfig !== undefined) { + return prevConfig.file !== nextConfig.file; + } + + // If both are undefined they are considered equal, otherwise + // they are different (as one is undefined but not the other). + return prevConfig === undefined && nextConfig === undefined; + }) + .map(config => config !== undefined + ? new PidFile(process.pid, config) + : undefined + ) + .let(observable => + destroyAndCreateOnNext( + observable, + pid => pid && pid.writeFile(), + pid => pid && pid.deleteFile() + ) + ) + .subscribe(); + } + + stop() { + this.stop$.next(true); + this.stop$.complete(); + } +} \ No newline at end of file diff --git a/platform/server/pid/index.ts b/platform/server/pid/index.ts new file mode 100644 index 000000000000000..e0a32275216f194 --- /dev/null +++ b/platform/server/pid/index.ts @@ -0,0 +1,2 @@ +export { PidService } from './PidService'; +export { PidConfig, schema } from './PidConfig'; diff --git a/scripts/platform.js b/scripts/platform.js new file mode 100644 index 000000000000000..fd4f9771921d3b2 --- /dev/null +++ b/scripts/platform.js @@ -0,0 +1,2 @@ +require('../src/optimize/babel/register'); +require('../ts-tmp/cli'); diff --git a/src/jest/config.js b/src/jest/config.js index ff927b9e0a92db8..0b1604c9b0fba38 100644 --- a/src/jest/config.js +++ b/src/jest/config.js @@ -1,7 +1,10 @@ import { resolve } from 'path'; export const config = { - roots: ['/ui_framework/'], + roots: [ + '/ui_framework/', + '/platform/' + ], collectCoverageFrom: [ 'ui_framework/services/**/*.js', '!ui_framework/services/index.js', @@ -12,13 +15,19 @@ export const config = { ], coverageDirectory: '/target/jest-coverage', coverageReporters: ['html'], - moduleFileExtensions: ['js', 'json'], - testMatch: ['**/*.test.js'], + globals: { + __TS_CONFIG__: { + target: "es6" + } + }, + moduleFileExtensions: ['ts', 'js', 'json'], + testMatch: ['**/*.test.js', '**/*.test.ts'], testPathIgnorePatterns: [ '[/\\\\]ui_framework[/\\\\](dist|doc_site|jest)[/\\\\]' ], transform: { - '^.+\\.js$': resolve(__dirname, './babelTransform.js') + '^.+\\.js$': resolve(__dirname, './babelTransform.js'), + '^.+\\.ts$': '/node_modules/ts-jest/preprocessor.js' }, transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.js$'], snapshotSerializers: ['/node_modules/enzyme-to-json/serializer'] diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000000000..ca7d79a3c9a44af --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compileOnSave": true, + "compilerOptions": { + // added to handle default exports when target is 'esnext', see + // https://github.com/Microsoft/TypeScript/issues/13340 + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "lib": ["dom", "es2017"], + "module": "es6", + "moduleResolution": "node", + "noImplicitAny": true, + "noImplicitReturns": false, + "noImplicitThis": true, + "outDir": "./ts-tmp", + "sourceMap": true, + "strictNullChecks": true, + "target": "esnext" + }, + "include": [ + "platform/**/*", + "types/index.d.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 000000000000000..aaa9ea3f98d2769 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1 @@ +/// diff --git a/types/type-detect/index.d.ts b/types/type-detect/index.d.ts new file mode 100644 index 000000000000000..a901627794f0ee0 --- /dev/null +++ b/types/type-detect/index.d.ts @@ -0,0 +1,4 @@ +declare module 'type-detect' { + export function typeDetect(obj: any): string; + export default typeDetect; +} \ No newline at end of file