diff --git a/.gitignore b/.gitignore index b04c40f74d13a5..b7788775dd5f51 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ selenium ui_framework/doc_site/build !ui_framework/doc_site/build/index.html yarn.lock +.vscode/ +ts-tmp/ diff --git a/package.json b/package.json index 7c7a8465fe3c8c..07184bc2d9bef6 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,9 @@ "mocha": "echo 'use `node scripts/mocha`' && false", "sterilize": "grunt sterilize", "uiFramework:start": "grunt uiFramework:start", - "uiFramework:build": "grunt uiFramework:build" + "uiFramework:build": "grunt uiFramework:build", + "ts:build": "tsc", + "ts:start": "tsc --watch" }, "repository": { "type": "git", @@ -109,6 +111,7 @@ "boom": "2.8.0", "brace": "0.5.1", "bunyan": "1.7.1", + "chalk": "1.1.3", "check-hash": "1.0.1", "color": "1.0.3", "commander": "2.8.1", @@ -123,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", @@ -185,6 +189,7 @@ "rimraf": "2.4.3", "rison-node": "1.0.0", "rjs-repack-loader": "1.0.6", + "rxjs": "5.4.0", "script-loader": "0.6.1", "semver": "5.1.0", "style-loader": "0.12.3", @@ -192,6 +197,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", "uuid": "3.0.1", @@ -199,12 +205,24 @@ "vision": "4.1.0", "webpack": "github:elastic/webpack#fix/query-params-for-aliased-loaders", "wreck": "6.2.0", + "yargs": "7.0.2", "yauzl": "2.7.0" }, "devDependencies": { "@elastic/eslint-config-kibana": "0.6.1", "@elastic/eslint-import-resolver-kibana": "0.8.1", "@elastic/eslint-plugin-kibana-custom": "1.0.3", + "@types/chalk": "0.4.31", + "@types/chance": "0.7.33", + "@types/elasticsearch": "5.0.13", + "@types/express": "4.0.35", + "@types/jest": "19.2.3", + "@types/js-yaml": "3.5.31", + "@types/lodash": "3.10.1", + "@types/node": "6.0.68", + "@types/sinon": "1.16.36", + "@types/supertest": "2.0.0", + "@types/yargs": "6.6.0", "angular-mocks": "1.4.7", "babel-eslint": "7.2.3", "chai": "3.5.0", @@ -276,6 +294,8 @@ "supertest": "3.0.0", "supertest-as-promised": "2.0.2", "tree-kill": "1.1.0", + "ts-jest": "20.0.6", + "typescript": "2.4.0", "webpack-dev-server": "1.14.1" }, "engines": { diff --git a/platform/README.md b/platform/README.md new file mode 100644 index 00000000000000..343e92ffdd7e95 --- /dev/null +++ b/platform/README.md @@ -0,0 +1,62 @@ +# New platform + +## Dev setup + +``` +npm run ts:start +``` + +This builds the code into `./ts-tmp/` for now. + +**NB** This will show a couple type errors, e.g. something like: + +``` +platform/server/elasticsearch/Cluster.ts(28,17): error TS2339: Property 'close' does not exist on type 'Client'. +platform/server/elasticsearch/Cluster.ts(29,23): error TS2339: Property 'close' does not exist on type 'Client'. +platform/server/http/SslConfig.ts(28,28): error TS2339: Property 'constants' does not exist on type 'typeof "crypto"'. +``` + +This is expected (for now), and it's related to some third-party types. + +## VSCode + +If you want to see what it looks like with fantastic editor support. + +``` +$ cat ~/.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`) + +Start the server and plugins: + +``` +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/cli/__tests__/__snapshots__/cli.test.ts.snap b/platform/cli/__tests__/__snapshots__/cli.test.ts.snap new file mode 100644 index 00000000000000..f252d77b6b44dc --- /dev/null +++ b/platform/cli/__tests__/__snapshots__/cli.test.ts.snap @@ -0,0 +1,266 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`displays help 1`] = ` +Object { + "errors": Array [], + "exit": true, + "logs": Array [ + "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. [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 +", + ], + "result": Object { + "$0": "--help", + "_": Array [], + "base-path": true, + "basePath": true, + "config": undefined, + "dev": undefined, + "elasticsearch": undefined, + "h": true, + "help": true, + "host": undefined, + "log-file": undefined, + "plugin-dir": undefined, + "port": undefined, + "quiet": undefined, + "silent": undefined, + "ssl": true, + "v": false, + "verbose": undefined, + "version": false, + "watch": true, + }, + "warnings": Array [], +} +`; + +exports[`displays version 1`] = ` +Object { + "errors": Array [], + "exit": true, + "logs": Array [ + "5.2.2", + ], + "result": Object { + "$0": "--version", + "_": Array [], + "base-path": true, + "basePath": true, + "config": undefined, + "dev": undefined, + "elasticsearch": undefined, + "h": false, + "help": false, + "host": undefined, + "log-file": undefined, + "plugin-dir": undefined, + "port": undefined, + "quiet": undefined, + "silent": undefined, + "ssl": true, + "v": true, + "verbose": undefined, + "version": true, + "watch": true, + }, + "warnings": Array [], +} +`; + +exports[`fails for unknown options 1`] = ` +Object { + "errors": Array [ + "The following options were not recognized: + [\\"foo\\"]", + "", + "Specify --help for available options", + ], + "exit": true, + "logs": Array [], + "result": Object { + "$0": "--foo", + "_": Array [], + "base-path": true, + "basePath": true, + "config": undefined, + "dev": undefined, + "elasticsearch": undefined, + "foo": true, + "h": false, + "help": false, + "host": undefined, + "log-file": undefined, + "plugin-dir": undefined, + "port": undefined, + "quiet": undefined, + "silent": undefined, + "ssl": true, + "v": false, + "verbose": undefined, + "version": false, + "watch": true, + }, + "warnings": Array [], +} +`; + +exports[`fails if config file does not exist 1`] = ` +Object { + "errors": Array [ + "Config file [/some-folder/kibana/config.yml] does not exist", + "", + "Specify --help for available options", + ], + "exit": true, + "logs": Array [], + "result": Object { + "$0": "--config /some-folder/kibana/config.yml", + "_": Array [], + "base-path": true, + "basePath": true, + "c": "/some-folder/kibana/config.yml", + "config": "/some-folder/kibana/config.yml", + "dev": undefined, + "elasticsearch": undefined, + "h": false, + "help": false, + "host": undefined, + "log-file": undefined, + "plugin-dir": undefined, + "port": undefined, + "quiet": undefined, + "silent": undefined, + "ssl": true, + "v": false, + "verbose": undefined, + "version": false, + "watch": true, + }, + "warnings": Array [], +} +`; + +exports[`fails if port is not a number 1`] = ` +Object { + "errors": Array [ + "[port] must be a number, but was a string", + "", + "Specify --help for available options", + ], + "exit": true, + "logs": Array [], + "result": Object { + "$0": "--port test", + "_": Array [], + "base-path": true, + "basePath": true, + "config": undefined, + "dev": undefined, + "elasticsearch": undefined, + "h": false, + "help": false, + "host": undefined, + "log-file": undefined, + "p": NaN, + "plugin-dir": undefined, + "port": NaN, + "quiet": undefined, + "silent": undefined, + "ssl": true, + "v": false, + "verbose": undefined, + "version": false, + "watch": true, + }, + "warnings": Array [], +} +`; + +exports[`handles args with dashes 1`] = ` +Object { + "errors": Array [], + "exit": false, + "logs": Array [], + "result": Object { + "$0": "--base-path", + "_": Array [], + "base-path": true, + "basePath": true, + "config": undefined, + "dev": undefined, + "elasticsearch": undefined, + "h": false, + "help": false, + "host": undefined, + "log-file": undefined, + "plugin-dir": undefined, + "port": undefined, + "quiet": undefined, + "silent": undefined, + "ssl": true, + "v": false, + "verbose": undefined, + "version": false, + "watch": true, + }, + "warnings": Array [], +} +`; + +exports[`handles negative args 1`] = ` +Object { + "errors": Array [], + "exit": false, + "logs": Array [], + "result": Object { + "$0": "--no-ssl --no-base-path", + "_": Array [], + "base-path": false, + "basePath": false, + "config": undefined, + "dev": undefined, + "elasticsearch": undefined, + "h": false, + "help": false, + "host": undefined, + "log-file": undefined, + "plugin-dir": undefined, + "port": undefined, + "quiet": undefined, + "silent": undefined, + "ssl": false, + "v": false, + "verbose": undefined, + "version": false, + "watch": true, + }, + "warnings": Array [], +} +`; diff --git a/platform/cli/__tests__/captureTerminal.ts b/platform/cli/__tests__/captureTerminal.ts new file mode 100644 index 00000000000000..1d50ac5309ef3e --- /dev/null +++ b/platform/cli/__tests__/captureTerminal.ts @@ -0,0 +1,72 @@ +import { clone } from 'lodash'; + +export function captureTerminal( + fn: (argv: T) => any, + argv: T +) { + const _exit = process.exit; + const _emit = process.emit; + const _env = process.env; + const _argv = process.argv; + + const _error = console.error; + const _log = console.log; + const _warn = console.warn; + + let exit = false; + process.exit = () => { + exit = true; + }; + + const env = clone(process.env); + env._ = 'node'; + process.env = env; + process.argv = argv; + + const errors: any[] = []; + const logs: any[] = []; + const warnings: any[] = []; + + console.error = (msg: any) => { + errors.push(msg); + }; + console.log = (msg: any) => { + logs.push(msg); + }; + console.warn = (msg: any) => { + warnings.push(msg); + }; + + let result: T; + + try { + result = fn(argv); + } finally { + reset(); + } + + return done(); + + function reset() { + process.exit = _exit; + process.emit = _emit; + process.env = _env; + process.argv = _argv; + + console.error = _error; + console.log = _log; + console.warn = _warn; + } + + function done() { + reset(); + + return { + errors, + logs, + warnings, + exit, + result + }; + } +} diff --git a/platform/cli/__tests__/cli.test.ts b/platform/cli/__tests__/cli.test.ts new file mode 100644 index 00000000000000..322adddc957bbd --- /dev/null +++ b/platform/cli/__tests__/cli.test.ts @@ -0,0 +1,36 @@ +jest.mock('../version', () => ({ + version: '5.2.2' +})); + +import { parseArgv } from '../cli'; +import { captureTerminal } from './captureTerminal'; + +test('displays help', () => { + expect(captureTerminal(parseArgv, ['--help'])).toMatchSnapshot(); +}); + +test('displays version', () => { + expect(captureTerminal(parseArgv, ['--version'])).toMatchSnapshot(); +}); + +test('fails for unknown options', () => { + expect(captureTerminal(parseArgv, ['--foo'])).toMatchSnapshot(); +}); + +test('fails if port is not a number', () => { + expect(captureTerminal(parseArgv, ['--port', 'test'])).toMatchSnapshot(); +}); + +test('fails if config file does not exist', () => { + expect(captureTerminal(parseArgv, ['--config', '/some-folder/kibana/config.yml'])).toMatchSnapshot(); +}); + +test('handles args with dashes', () => { + expect(captureTerminal(parseArgv, ['--base-path'])).toMatchSnapshot(); +}); + +test('handles negative args', () => { + expect( + captureTerminal(parseArgv, ['--no-ssl', '--no-base-path']) + ).toMatchSnapshot(); +}); diff --git a/platform/cli/args.ts b/platform/cli/args.ts new file mode 100644 index 00000000000000..6cab20ffe5ca9e --- /dev/null +++ b/platform/cli/args.ts @@ -0,0 +1,146 @@ +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.', + type: 'string', + coerce: arg => { + if (typeof arg === 'string') { + return resolve(arg); + } + 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)) { + throw new Error(`Multiple config files is not allowed`); + } + + 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 00000000000000..fcc4b9237d3f94 --- /dev/null +++ b/platform/cli/cli.ts @@ -0,0 +1,37 @@ +// TODO Fix build system so we can switch these to `import`s +const yargs = require('yargs'); + +import * as args from './args'; +import { version } from './version'; +import { Env } from '../config'; +import { Root } from '../root'; + +export const parseArgv = (argv: Array) => + yargs(argv) + .usage(args.usage + '\n\n' + args.description) + .version(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 root = new Root(argv, env); + root.start(); + + process.on('SIGHUP', () => root.reloadConfig()); + process.on('SIGINT', () => root.shutdown()); + process.on('SIGTERM', () => root.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 00000000000000..04bf53285ba672 --- /dev/null +++ b/platform/cli/index.ts @@ -0,0 +1,3 @@ +import cli from './cli'; + +cli(process.argv.slice(2)); diff --git a/platform/cli/version.ts b/platform/cli/version.ts new file mode 100644 index 00000000000000..f495c897a07ae5 --- /dev/null +++ b/platform/cli/version.ts @@ -0,0 +1,3 @@ +const pkg = require('../../package.json'); + +export const version: string = pkg.version; diff --git a/platform/config/ConfigService.ts b/platform/config/ConfigService.ts new file mode 100644 index 00000000000000..0b585084349942 --- /dev/null +++ b/platform/config/ConfigService.ts @@ -0,0 +1,171 @@ +import { BehaviorSubject, Observable } from 'rxjs'; +import { get, isEqual } from 'lodash'; + +import { getConfigFromFile } from './readConfig'; +import { applyArgv } from './applyArgv'; +import { Env } from './Env'; +import { Logger, LoggerFactory } from '../logger'; +import * as schema from '../lib/schema'; +import { ConfigWithSchema } from '../types'; + +interface RawConfig { + [key: string]: any +}; + +type ConfigPath = string | string[]; + +export class ConfigService { + /** + * The stream of configs read from the config file. Will only be `undefined` + * before the config is initially read. This is the _raw_ config before any + * argv or similar is applied. + * + * As we have a notion of a _current_ config we rely on a BehaviorSubject so + * every new subscription will immediately receive the current config. + */ + private readonly rawConfigFromFile$: BehaviorSubject = + new BehaviorSubject(undefined) + + private readonly config$: Observable; + private readonly log: Logger; + + /** + * Whenever a config if read at a path, we mark that path as 'handled'. We can + * then list all unhandled config paths when the startup process is completed. + */ + private readonly handledPaths: ConfigPath[] = []; + + constructor( + private readonly argv: {[key: string]: any}, + readonly env: Env, + logger: LoggerFactory + ) { + this.log = logger.get('config'); + + this.config$ = this.rawConfigFromFile$ + .filter(rawConfig => rawConfig !== undefined) + // Now we _know_ `RawConfig` can no longer be `undefined`, but we can't + // express that with TS types yet, so below we just _tell_ TS that it is + // guaranteed to no longer be `undefined`. + .map(rawConfig => rawConfig!) + // We only want to update the config if there are changes to it + .distinctUntilChanged((current, next) => isEqual(current, next)) + .map(rawConfig => applyArgv(argv, rawConfig)); + } + + /** + * Read the initial Kibana config. + */ + start() { + this.loadConfig(); + } + + /** + * Re-read the Kibana config. + */ + reloadConfig() { + this.log.info('reloading config'); + this.loadConfig(); + this.log.info('reloading config done'); + } + + /** + * Load the config by reading the raw config from the file system. + */ + private loadConfig() { + const config = getConfigFromFile(this.argv.config, this.env.getDefaultConfigFile()); + this.rawConfigFromFile$.next(config); + } + + stop() { + this.rawConfigFromFile$.complete(); + } + + /** + * Reads the subset of the config at the specified `path` and validates it + * against the schema created by calling the static `createSchema` on the + * specified `ConfigClass`. + * + * @param path The path to the desired subset of the config. + * @param ConfigClass A class (not an instance of a class) that contains a + * static `createSchema` that will be called to create a + * schema that we validate the config at the given `path` + * against. + */ + atPath( + path: ConfigPath, + ConfigClass: ConfigWithSchema + ) { + return this.getDistinctRawConfig(path) + .map(rawConfig => this.createConfig(rawConfig, ConfigClass)); + } + + /** + * Same as `atPath`, but returns `undefined` if there is no config at the + * specified path. + * + * @see atPath + */ + optionalAtPath( + path: ConfigPath, + ConfigClass: ConfigWithSchema + ) { + return this.getDistinctRawConfig(path) + .map(rawConfig => + rawConfig === undefined + ? undefined + : this.createConfig(rawConfig, ConfigClass) + ); + } + + private createConfig( + rawConfig: {}, + ConfigClass: ConfigWithSchema + ) { + const config = ConfigClass.createSchema(schema).validate(rawConfig); + return new ConfigClass(config, this.env); + } + + private getDistinctRawConfig(path: ConfigPath) { + this.handledPaths.push(path); + + return this.config$ + .map(config => get(config, path)) + .distinctUntilChanged((prev, next) => isEqual(prev, next)) + } + + async getUnusedPaths(): Promise { + const config = await this.config$.first().toPromise(); + const flatConfigPaths = [...flattenObject(config)].map(obj => obj.key); + const handledPaths = this.handledPaths.map(pathToString); + + return flatConfigPaths.filter(path => + !isPathHandled(path, handledPaths) + ); + } +} + +const pathToString = (path: ConfigPath) => + Array.isArray(path) + ? path.join('.') + : path; + +/** + * A path is considered 'handled' if it is a subset of any of the already + * handled paths. + */ +const isPathHandled = (path: string, handledPaths: string[]) => + handledPaths.some(handledPath => path.startsWith(handledPath)); + +function* flattenObject( + obj: { [key: string]: any }, + accKey: string = '' +): IterableIterator<{ key: string, value: any }> { + if (typeof obj !== 'object') { + yield { key: accKey, value: obj }; + } else { + for (const key in obj) { + yield* flattenObject(obj[key], (accKey !== '' ? accKey + '.' : '') + key); + } + } +} diff --git a/platform/config/Env.ts b/platform/config/Env.ts new file mode 100644 index 00000000000000..0ca0a1b9d2cc0b --- /dev/null +++ b/platform/config/Env.ts @@ -0,0 +1,30 @@ +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) { + // TODO Fix path, should not be `ts-tmp`, that was only to get stuff running + const platformDir = resolve(this.homeDir, 'ts-tmp'); + + this.configDir = resolve(this.homeDir, 'config'); + this.pluginsDir = resolve(platformDir, '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'); + } +} diff --git a/platform/config/__tests__/ConfigService.test.ts b/platform/config/__tests__/ConfigService.test.ts new file mode 100644 index 00000000000000..5c5a6b9dab7fd1 --- /dev/null +++ b/platform/config/__tests__/ConfigService.test.ts @@ -0,0 +1,276 @@ +const mockGetConfigFromFile = jest.fn(); + +jest.mock('../readConfig', () => ({ + getConfigFromFile: mockGetConfigFromFile +})); + +import { ConfigService } from '../ConfigService'; +import { Env } from '../Env'; +import { logger } from '../../logger/__mocks__' +import { Schema } from '../../types'; +import * as schemaLib from '../../lib/schema' + +beforeEach(() => { + mockGetConfigFromFile.mockReset(); + mockGetConfigFromFile.mockImplementation(() => ({})); +}); + +test('loads raw config when started', () => { + const argv = {}; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + expect(mockGetConfigFromFile).toHaveBeenCalledTimes(1); + expect(mockGetConfigFromFile).toHaveBeenLastCalledWith(undefined, '/kibana/config/kibana.yml'); +}); + +test('specifies additional config files if in argv when started', () => { + const argv = { + config: '/my/special/kibana/config.yml' + }; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + expect(mockGetConfigFromFile).toHaveBeenCalledTimes(1); + expect(mockGetConfigFromFile).toHaveBeenLastCalledWith( + '/my/special/kibana/config.yml', + '/kibana/config/kibana.yml' + ); +}); + +test('re-reads the config when reloading', () => { + const argv = { + config: '/my/special/kibana/config.yml' + }; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + mockGetConfigFromFile.mockClear(); + mockGetConfigFromFile.mockImplementation(() => ({ foo: 'bar' })); + + configService.reloadConfig(); + + expect(mockGetConfigFromFile).toHaveBeenCalledTimes(1); + expect(mockGetConfigFromFile).toHaveBeenLastCalledWith( + '/my/special/kibana/config.yml', + '/kibana/config/kibana.yml' + ); +}); + +test('returns config at path as observable', async () => { + mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + + const argv = {}; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + const configs = configService.atPath('key', ExampleClassWithStringSchema); + + const exampleConfig = await configs.first().toPromise(); + + expect(exampleConfig.value).toBe('value'); +}); + +test('throws if config at path does not match schema', async () => { + expect.assertions(1); + + mockGetConfigFromFile.mockImplementation(() => ({ key: 123 })); + + const argv = {}; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + const configs = configService.atPath('key', ExampleClassWithStringSchema); + + try { + await configs.first().toPromise(); + } catch (e) { + expect(e.message).toMatchSnapshot(); + } +}); + +test("returns undefined if fetching optional config at a path that doesn't exist", async () => { + mockGetConfigFromFile.mockImplementation(() => ({ foo: 'bar' })); + + const argv = {}; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + const configs = configService.optionalAtPath('unique-name', ExampleClassWithStringSchema); + + const exampleConfig = await configs.first().toPromise(); + + expect(exampleConfig).toBeUndefined(); +}); + +test("returns observable config at optional path if it exists", async () => { + mockGetConfigFromFile.mockImplementation(() => ({ value: 'bar' })); + + const argv = {}; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + const configs = configService.optionalAtPath('value', ExampleClassWithStringSchema); + + const exampleConfig: any = await configs.first().toPromise(); + + expect(exampleConfig).toBeDefined(); + expect(exampleConfig.value).toBe('bar'); +}); + +test("does not push new configs when reloading if config at path hasn't changed", async () => { + mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + + const argv = {}; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + const valuesReceived: any[] = []; + configService.atPath('key', ExampleClassWithStringSchema) + .subscribe(config => { + valuesReceived.push(config.value); + }); + + mockGetConfigFromFile.mockClear(); + mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + + configService.reloadConfig(); + + expect(valuesReceived).toEqual(['value']); +}); + +test("pushes new config when reloading and config at path has changed", async () => { + mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + + const argv = {}; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + const valuesReceived: any[] = []; + configService.atPath('key', ExampleClassWithStringSchema) + .subscribe(config => { + valuesReceived.push(config.value); + }); + + mockGetConfigFromFile.mockClear(); + mockGetConfigFromFile.mockImplementation(() => ({ key: 'new value' })); + + configService.reloadConfig(); + + expect(valuesReceived).toEqual(['value', 'new value']); +}); + +test("throws error if config class does not implement 'createSchema'", async () => { + expect.assertions(1); + + class ExampleClass { + } + + const argv = {}; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + const configs = configService.atPath('key', ExampleClass as any); + + try { + await configs.first().toPromise(); + } catch(e) { + expect(e).toMatchSnapshot(); + } +}); + +test('completes config observables when stopped', (done) => { + expect.assertions(0); + + mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + + const argv = {}; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + configService.atPath('key', ExampleClassWithStringSchema) + .subscribe({ + complete: () => done() + }); + + configService.stop(); +}); + +test("tracks unhandled paths", async () => { + mockGetConfigFromFile.mockImplementation(() => ({ + foo: 'value', + bar: { + deep1: { + key: '123' + }, + deep2: { + key: '321' + } + }, + quux: { + deep1: { + key: 'hello' + }, + deep2: { + key: 'world' + } + } + })); + + const argv = {}; + const env = new Env('/kibana'); + const configService = new ConfigService(argv, env, logger); + + configService.start(); + + configService.atPath('foo', createClassWithSchema(schemaLib.string())); + configService.atPath(['bar', 'deep2'], createClassWithSchema(schemaLib.object({ + key: schemaLib.string() + }))); + + const unused = await configService.getUnusedPaths(); + + expect(unused).toEqual(["bar.deep1.key", "quux.deep1.key", "quux.deep2.key"]); +}); + +function createClassWithSchema(schema: schemaLib.Any) { + return class ExampleClassWithSchema { + static createSchema = () => { + return schema; + } + + constructor(readonly value: schemaLib.TypeOf) { + } + } +} + +class ExampleClassWithStringSchema { + static createSchema = (schema: Schema) => { + return schema.string(); + } + + constructor(readonly value: string) { + } +} diff --git a/platform/config/__tests__/__snapshots__/ConfigService.test.ts.snap b/platform/config/__tests__/__snapshots__/ConfigService.test.ts.snap new file mode 100644 index 00000000000000..9e459282034f25 --- /dev/null +++ b/platform/config/__tests__/__snapshots__/ConfigService.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws error if config class does not implement 'createSchema' 1`] = `[TypeError: ConfigClass.createSchema is not a function]`; + +exports[`throws if config at path does not match schema 1`] = `"expected value of type [string] but got [number]"`; diff --git a/platform/config/__tests__/applyArgv.test.ts b/platform/config/__tests__/applyArgv.test.ts new file mode 100644 index 00000000000000..2fc35479c2b601 --- /dev/null +++ b/platform/config/__tests__/applyArgv.test.ts @@ -0,0 +1,39 @@ +import { applyArgv } from '../applyArgv'; + +test('can override port', () => { + const argv = { + port: 123 + }; + const config = { + server: { + port: 5601, + host: 'localhost' + } + } + + expect(applyArgv(argv, config)).toEqual({ + server: { + port: 123, + host: 'localhost' + } + }); +}); + +test('can override host', () => { + const argv = { + host: 'example.org' + }; + const config = { + server: { + port: 5601, + host: 'localhost' + } + } + + expect(applyArgv(argv, config)).toEqual({ + server: { + port: 5601, + host: 'example.org' + } + }); +}); diff --git a/platform/config/__tests__/readConfig.test.ts b/platform/config/__tests__/readConfig.test.ts new file mode 100644 index 00000000000000..9b6dd856d31079 --- /dev/null +++ b/platform/config/__tests__/readConfig.test.ts @@ -0,0 +1,34 @@ +const mockReadFileSync = jest.fn(); + +jest.mock('fs', () => ({ + readFileSync: mockReadFileSync +})); + +import { getConfigFromFile } from '../readConfig'; + +test('reads yaml from file system and parses to json', () => { + const yaml = 'key: value'; + mockReadFileSync.mockImplementation(() => yaml); + + expect(getConfigFromFile(undefined, './config.yml')).toEqual({ + key: 'value' + }); +}); + +test('reads from default config file if no other config file specified', () => { + const yaml = ''; + mockReadFileSync.mockImplementation(() => yaml); + + getConfigFromFile(undefined, './config.yml'); + + expect(mockReadFileSync).toHaveBeenLastCalledWith('./config.yml', 'utf8'); +}); + +test('reads from specified config file if given', () => { + const yaml = ''; + mockReadFileSync.mockImplementation(() => yaml); + + getConfigFromFile('./some-other-config-file.yml', './config.yml'); + + expect(mockReadFileSync).toHaveBeenLastCalledWith('./some-other-config-file.yml', 'utf8'); +}); diff --git a/platform/config/applyArgv.ts b/platform/config/applyArgv.ts new file mode 100644 index 00000000000000..aabd8218dc720a --- /dev/null +++ b/platform/config/applyArgv.ts @@ -0,0 +1,24 @@ +import { set, cloneDeep } from 'lodash'; + +/** + * Applies the values from argv to the input config object and returns a new + * config object. Does not mutate the input object. + * + * @param argv Argv object with key/value pairs + * @param config Config object + */ +export function applyArgv( + argv: { [key: string]: any }, + config: { [key: string]: any } +) { + config = cloneDeep(config); + + if (argv.port != null) { + set(config, ['server', 'port'], argv.port); + } + if (argv.host != null) { + set(config, ['server', 'host'], argv.host); + } + + return config; +} \ No newline at end of file diff --git a/platform/config/index.ts b/platform/config/index.ts new file mode 100644 index 00000000000000..9815a9d47973c3 --- /dev/null +++ b/platform/config/index.ts @@ -0,0 +1,2 @@ +export { ConfigService } from './ConfigService'; +export { Env } from './Env'; diff --git a/platform/config/readConfig.ts b/platform/config/readConfig.ts new file mode 100644 index 00000000000000..72339ea78ce886 --- /dev/null +++ b/platform/config/readConfig.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'fs'; +import { safeLoad } from 'js-yaml'; + +const readYaml = (path: string) => + safeLoad(readFileSync(path, 'utf8')); + +export function getConfigFromFile(configFile: string | undefined, defaultConfigFile: string) { + const file = configFile === undefined + ? defaultConfigFile + : configFile; + + return readYaml(file); +} diff --git a/platform/lib/ByteSizeValue/__tests__/index.test.ts b/platform/lib/ByteSizeValue/__tests__/index.test.ts new file mode 100644 index 00000000000000..d694dbff593c8a --- /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 00000000000000..a659482f90cec3 --- /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/Errors/__tests__/__snapshots__/index.test.ts.snap b/platform/lib/Errors/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000000000..7b10fc7c0425e1 --- /dev/null +++ b/platform/lib/Errors/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`includes stack 1`] = ` +"Error: test + at KibanaError (platform/lib/Errors/index.ts:11:5) + at Object..e (platform/lib/Errors/__tests__/index.test.ts:6:11)" +`; diff --git a/platform/lib/Errors/__tests__/cleanStack.ts b/platform/lib/Errors/__tests__/cleanStack.ts new file mode 100644 index 00000000000000..edc98de12e67f6 --- /dev/null +++ b/platform/lib/Errors/__tests__/cleanStack.ts @@ -0,0 +1,22 @@ +import { relative } from 'path'; + +/** + * Make all paths in stacktrace relative. + */ +export const cleanStack = (stack: string) => + stack.split('\n') + .filter(line => + !line.includes('node_modules/') && + !line.includes('internal/') + ) + .map(line => { + const parts = /.*\((.*)\).?/.exec(line) || []; + + if (parts.length === 0) { + return line; + } + + const path = parts[1]; + return line.replace(path, relative(process.cwd(), path)); + }) + .join('\n'); diff --git a/platform/lib/Errors/__tests__/index.test.ts b/platform/lib/Errors/__tests__/index.test.ts new file mode 100644 index 00000000000000..6da8d743deb785 --- /dev/null +++ b/platform/lib/Errors/__tests__/index.test.ts @@ -0,0 +1,10 @@ +import { KibanaError } from '../'; +import { cleanStack } from './cleanStack'; + +test('includes stack', () => { + try { + throw new KibanaError('test') + } catch (e) { + expect(cleanStack(e.stack)).toMatchSnapshot(); + } +}); diff --git a/platform/lib/Errors/index.ts b/platform/lib/Errors/index.ts new file mode 100644 index 00000000000000..a203b0f8f4d71d --- /dev/null +++ b/platform/lib/Errors/index.ts @@ -0,0 +1,17 @@ +// TODO https://github.com/sindresorhus/clean-stack for cleaner stack +export class KibanaError extends Error { + // `cause` lets us chain errors, e.g. so we can wrap underlying errors and + // get a "full" stack trace that includes the causes. + // TODO Handle stacks from `cause`, see e.g. + // - https://github.com/joyent/node-verror + // - https://github.com/bluejamesbond/TraceError.js + cause?: Error; + + constructor(message: string, cause?: Error) { + super(message); + this.cause = cause; + + // Set the prototype explicitly, see https://goo.gl/bTnzz2 + Object.setPrototypeOf(this, KibanaError.prototype); + } +} diff --git a/platform/lib/schema/SettingError.ts b/platform/lib/schema/SettingError.ts new file mode 100644 index 00000000000000..bbfe3dbe627734 --- /dev/null +++ b/platform/lib/schema/SettingError.ts @@ -0,0 +1,46 @@ +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 message; + } + return `[${context}]: ${message}`; + } + + static extractCause(error: Error | string): Error | undefined { + return typeof error !== 'string' ? error : undefined; + } +} + +export class SettingsError extends KibanaError { + constructor(errors: Array, message: string, key?: string) { + super( + SettingsError.extractMessages(errors, message, key), + SettingsError.extractFirstCause(errors) + ); + } + + static extractMessages(error: Array, heading: string, context?: string) { + const messages = `- ${error.map(e => e.message).join('\n- ')}` + + if (context == null) { + return `${heading}:\n${messages}`; + } + return `[${context}]: ${heading}:\n${messages}`; + } + + static extractFirstCause(error: Array): Error | undefined { + return error.length > 0 + ? error[0] + : undefined; + } +} diff --git a/platform/lib/schema/__tests__/ArraySetting.test.ts b/platform/lib/schema/__tests__/ArraySetting.test.ts new file mode 100644 index 00000000000000..bc3d8f96f6856f --- /dev/null +++ b/platform/lib/schema/__tests__/ArraySetting.test.ts @@ -0,0 +1,119 @@ +import { arrayOf, string, object, maybe } from '../'; + +test('returns value if it matches the type', () => { + 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 00000000000000..518b9290c0f5dd --- /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 00000000000000..f4e4bd0440a513 --- /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__/DurationSetting.test.ts b/platform/lib/schema/__tests__/DurationSetting.test.ts new file mode 100644 index 00000000000000..aa240bab1f057e --- /dev/null +++ b/platform/lib/schema/__tests__/DurationSetting.test.ts @@ -0,0 +1,37 @@ +import { duration as momentDuration } from 'moment'; + +import { duration } from '../'; + +test('returns value by default', () => { + expect(duration().validate('123s')).toMatchSnapshot(); +}); + +test('is required by default', () => { + expect(() => duration().validate(undefined)).toThrowErrorMatchingSnapshot(); +}); + +describe('#defaultValue', () => { + test('can be a moment.Duration', () => { + expect( + duration({ + defaultValue: momentDuration(1, 'hour') + }).validate(undefined) + ).toMatchSnapshot(); + }); + + test('can be a string', () => { + expect( + duration({ + defaultValue: '1h' + }).validate(undefined) + ).toMatchSnapshot(); + }); +}); + +test('returns error when not string', () => { + expect(() => duration().validate(123)).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate(/abc/)).toThrowErrorMatchingSnapshot(); +}); diff --git a/platform/lib/schema/__tests__/LiteralSetting.test.ts b/platform/lib/schema/__tests__/LiteralSetting.test.ts new file mode 100644 index 00000000000000..aaa70df682daeb --- /dev/null +++ b/platform/lib/schema/__tests__/LiteralSetting.test.ts @@ -0,0 +1,23 @@ +import { literal } from '../'; + +test('handles string', () => { + expect(literal('test').validate('test')).toBe('test'); +}); + +test('handles boolean', () => { + expect(literal(false).validate(false)).toBe(false); +}); + +test('handles number', () => { + expect(literal(123).validate(123)).toBe(123); +}); + +test('returns error when not corrent', () => { + expect(() => literal('test').validate('foo')).toThrowErrorMatchingSnapshot(); + + expect(() => literal(true).validate(false)).toThrowErrorMatchingSnapshot(); + + expect(() => literal('test').validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => literal(123).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 00000000000000..6f3246aa0f8a76 --- /dev/null +++ b/platform/lib/schema/__tests__/MaybeSetting.test.ts @@ -0,0 +1,36 @@ +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('returns undefined even if contained setting has a default value', () => { + const setting = maybe(string({ + defaultValue: 'abc' + })); + + expect(setting.validate(undefined)).toEqual(undefined); +}); + +test('calls validate on contained setting', () => { + const spy = jest.fn(); + + const setting = maybe(string({ + validate: spy + })); + + setting.validate('foo'); + + expect(spy).toHaveBeenCalledWith('foo'); +}); + +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 00000000000000..7184669ead0435 --- /dev/null +++ b/platform/lib/schema/__tests__/NumberSetting.test.ts @@ -0,0 +1,59 @@ +import { number } from '../'; + +test('returns value by default', () => { + expect(number().validate(4)).toBe(4); +}); + +test('handles numeric strings with ints', () => { + expect(number().validate('4')).toBe(4); +}); + +test('handles numeric strings with floats', () => { + expect(number().validate('4.23')).toBe(4.23); +}); + +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 or numeric string', () => { + 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 00000000000000..239223eb96dfe9 --- /dev/null +++ b/platform/lib/schema/__tests__/ObjectSetting.test.ts @@ -0,0 +1,110 @@ +import { object, string, oneOf } 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' + } + }); + }); +}); + +test('called with wrong type', () => { + const setting = object({}); + + expect(() => setting.validate('foo')).toThrowErrorMatchingSnapshot(); + expect(() => setting.validate(123)).toThrowErrorMatchingSnapshot(); +}); + +test('handles oneOf', () => { + const setting = object({ + key: oneOf([string()]) + }); + + expect(setting.validate({ key: 'foo' })).toEqual({ key: 'foo' }); + expect(() => setting.validate({ key: 123 })).toThrowErrorMatchingSnapshot(); +}); diff --git a/platform/lib/schema/__tests__/OneOfSetting.test.ts b/platform/lib/schema/__tests__/OneOfSetting.test.ts new file mode 100644 index 00000000000000..36cc8f0bd9400f --- /dev/null +++ b/platform/lib/schema/__tests__/OneOfSetting.test.ts @@ -0,0 +1,102 @@ +import { oneOf, string, number, literal, object, maybe } from '../'; + +test('handles string', () => { + expect(oneOf([string()]).validate('test')).toBe('test'); +}); + +test('handles string with default', () => { + const setting = oneOf([string()], { + defaultValue: 'test' + }); + + expect(setting.validate(undefined)).toBe('test'); +}); + +test('handles number', () => { + expect(oneOf([number()]).validate(123)).toBe(123); +}); + +test('handles number with default', () => { + const setting = oneOf([number()], { + defaultValue: 123 + }); + + expect(setting.validate(undefined)).toBe(123); +}); + +test('handles literal', () => { + const setting = oneOf([literal('foo')]); + + expect(setting.validate('foo')).toBe('foo'); +}); + +test('handles literal with default', () => { + const setting = oneOf([literal('foo')], { + defaultValue: 'foo' + }); + + expect(setting.validate(undefined)).toBe('foo'); +}); + +test('handles multiple literals with default', () => { + const setting = oneOf([literal('foo'), literal('bar')], { + defaultValue: 'bar' + }); + + expect(setting.validate('foo')).toBe('foo'); + expect(setting.validate(undefined)).toBe('bar'); +}); + +test('handles object', () => { + const setting = oneOf([object({ name: string() })]); + + expect(setting.validate({ name: 'foo' })).toEqual({ name: 'foo' }); +}); + +test('handles object with wrong type', () => { + const setting = oneOf([object({ age: number() })]); + + expect(() => setting.validate({ age: 'foo' })).toThrowErrorMatchingSnapshot(); +}); + +test('handles multiple objects with same key', () => { + const setting = oneOf([ + object({ age: string() }), + object({ age: number() }) + ]); + + expect(setting.validate({ age: 'foo' })).toEqual({ age: 'foo' }); +}); + +test('handles multiple types', () => { + const setting = oneOf([string(), number()]); + + expect(setting.validate('test')).toBe('test'); + expect(setting.validate(123)).toBe(123); +}) + +test('handles maybe', () => { + const setting = oneOf([maybe(string())]); + + expect(setting.validate(undefined)).toBe(undefined); + expect(setting.validate('test')).toBe('test'); +}); + +test('fails if not matching type', () => { + const setting = oneOf([string()]); + + expect(() => setting.validate(false)).toThrowErrorMatchingSnapshot(); + expect(() => setting.validate(123)).toThrowErrorMatchingSnapshot(); +}) + +test('fails if not matching multiple types', () => { + const setting = oneOf([string(), number()]); + + expect(() => setting.validate(false)).toThrowErrorMatchingSnapshot(); +}) + +test('fails if not matching literal', () => { + const setting = oneOf([literal('foo')]); + + expect(() => setting.validate('bar')).toThrowErrorMatchingSnapshot(); +}) \ No newline at end of file diff --git a/platform/lib/schema/__tests__/StringSetting.test.ts b/platform/lib/schema/__tests__/StringSetting.test.ts new file mode 100644 index 00000000000000..7baae4f92b2c6e --- /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 00000000000000..cb55a61619d459 --- /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`] = `"array size is [2], but cannot be greater than [1]"`; + +exports[`#minSize returns error when fewer items 1`] = `"array size is [1], but cannot be smaller than [2]"`; + +exports[`fails for null values if optional 1`] = `"[0]: expected value to either be undefined or defined, but not [null]"`; + +exports[`fails if mixed types of content in array 1`] = `"[2]: expected value of type [string] but got [boolean]"`; + +exports[`fails if wrong input type 1`] = `"expected value of type [array] but got [string]"`; + +exports[`fails if wrong type of content in array 1`] = `"[0]: expected value of type [string] but got [number]"`; + +exports[`object within array with required 1`] = `"[0.foo]: expected value of type [string] but got [undefined]"`; 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 00000000000000..c396b970328a72 --- /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`] = `"expected value of type [boolean] but got [undefined]"`; + +exports[`returns error when not boolean 1`] = `"expected value of type [boolean] but got [number]"`; + +exports[`returns error when not boolean 2`] = `"expected value of type [boolean] but got [Array]"`; + +exports[`returns error when not boolean 3`] = `"expected value of type [boolean] but got [string]"`; 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 00000000000000..b639e86c05c682 --- /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`] = `"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`] = `"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`] = `"expected value of type [ByteSize] but got [undefined]"`; + +exports[`returns error when not string 1`] = `"expected value of type [ByteSize] but got [number]"`; + +exports[`returns error when not string 2`] = `"expected value of type [ByteSize] but got [Array]"`; + +exports[`returns error when not string 3`] = `"expected value of type [ByteSize] but got [RegExp]"`; + +exports[`returns value by default 1`] = ` +ByteSizeValue { + "valueInBytes": 123, +} +`; diff --git a/platform/lib/schema/__tests__/__snapshots__/DurationSetting.test.ts.snap b/platform/lib/schema/__tests__/__snapshots__/DurationSetting.test.ts.snap new file mode 100644 index 00000000000000..4a9fc270b29ecd --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/DurationSetting.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#defaultValue can be a moment.Duration 1`] = `"PT1H"`; + +exports[`#defaultValue can be a string 1`] = `"PT1H"`; + +exports[`is required by default 1`] = `"expected value of type [moment.Duration] but got [undefined]"`; + +exports[`returns error when not string 1`] = `"expected value of type [moment.Duration] but got [number]"`; + +exports[`returns error when not string 2`] = `"expected value of type [moment.Duration] but got [Array]"`; + +exports[`returns error when not string 3`] = `"expected value of type [moment.Duration] but got [RegExp]"`; + +exports[`returns value by default 1`] = `"PT2M3S"`; diff --git a/platform/lib/schema/__tests__/__snapshots__/LiteralSetting.test.ts.snap b/platform/lib/schema/__tests__/__snapshots__/LiteralSetting.test.ts.snap new file mode 100644 index 00000000000000..70d7f2124d5757 --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/LiteralSetting.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`returns error when not corrent 1`] = `"expected value to equal [test] but got [foo]"`; + +exports[`returns error when not corrent 2`] = `"expected value to equal [true] but got [false]"`; + +exports[`returns error when not corrent 3`] = `"expected value to equal [test] but got [1,2,3]"`; + +exports[`returns error when not corrent 4`] = `"expected value to equal [123] but got [abc]"`; 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 00000000000000..3ad6c9f6409286 --- /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`] = `"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 00000000000000..f1780bdfbbe325 --- /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`] = `"Value is [3] but it must be equal to or lower than [2]."`; + +exports[`#min returns error when smaller number 1`] = `"Value is [3] but it must be equal to or greater than [4]."`; + +exports[`fails if number is \`NaN\` 1`] = `"expected value of type [number] but got [number]"`; + +exports[`is required by default 1`] = `"expected value of type [number] but got [undefined]"`; + +exports[`returns error when not number or numeric string 1`] = `"expected value of type [number] but got [string]"`; + +exports[`returns error when not number or numeric string 2`] = `"expected value of type [number] but got [Array]"`; + +exports[`returns error when not number or numeric string 3`] = `"expected value of type [number] but got [RegExp]"`; 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 00000000000000..8e9efaca25b991 --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/ObjectSetting.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`called with wrong type 1`] = `"expected a plain object value, but found [string] instead."`; + +exports[`called with wrong type 2`] = `"expected a plain object value, but found [number] instead."`; + +exports[`fails if key does not exist in schema 1`] = `"missing definitions in schema for keys [bar]"`; + +exports[`fails if missing string 1`] = `"[name]: expected value of type [string] but got [undefined]"`; + +exports[`handles oneOf 1`] = ` +"[key]: settings that failed validation: +- [key.0]: expected value of type [string] but got [number]" +`; + +exports[`object within object with required 1`] = `"[foo.bar]: expected value of type [string] but got [undefined]"`; diff --git a/platform/lib/schema/__tests__/__snapshots__/OneOfSetting.test.ts.snap b/platform/lib/schema/__tests__/__snapshots__/OneOfSetting.test.ts.snap new file mode 100644 index 00000000000000..44acf27b0ef84f --- /dev/null +++ b/platform/lib/schema/__tests__/__snapshots__/OneOfSetting.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`fails if not matching literal 1`] = ` +"settings that failed validation: +- [0]: expected value to equal [foo] but got [bar]" +`; + +exports[`fails if not matching multiple types 1`] = ` +"settings that failed validation: +- [0]: expected value of type [string] but got [boolean] +- [1]: expected value of type [number] but got [boolean]" +`; + +exports[`fails if not matching type 1`] = ` +"settings that failed validation: +- [0]: expected value of type [string] but got [boolean]" +`; + +exports[`fails if not matching type 2`] = ` +"settings that failed validation: +- [0]: expected value of type [string] but got [number]" +`; + +exports[`handles object with wrong type 1`] = ` +"settings that failed validation: +- [0.age]: expected value of type [number] but got [string]" +`; 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 00000000000000..5799fedd6f424a --- /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`] = `"value is [foo] but it must have a maximum length of [2]."`; + +exports[`#minLength returns error when shorter string 1`] = `"value is [foo] but it must have a minimum length of [4]."`; + +exports[`#validate throws when returns string 1`] = `"validator failure"`; + +exports[`is required by default 1`] = `"expected value of type [string] but got [undefined]"`; + +exports[`returns error when not string 1`] = `"expected value of type [string] but got [number]"`; + +exports[`returns error when not string 2`] = `"expected value of type [string] but got [Array]"`; + +exports[`returns error when not string 3`] = `"expected value of type [string] but got [RegExp]"`; diff --git a/platform/lib/schema/index.ts b/platform/lib/schema/index.ts new file mode 100644 index 00000000000000..190a3f95bca859 --- /dev/null +++ b/platform/lib/schema/index.ts @@ -0,0 +1,478 @@ +// TODO Change to require, had problems with `.default` +const typeDetect = require('type-detect'); +import { difference, isPlainObject } from 'lodash'; +import { duration as momentDuration, isDuration, Duration } from 'moment'; + +import { SettingError, SettingsError } from './SettingError'; +import { ByteSizeValue } from '../ByteSizeValue'; + +function toContext(parent: string = '', child: string | number) { + return parent ? `${parent}.${child}` : String(child); +} + +export type Any = Setting +export type TypeOf = RT['_type']; + +type SettingOptions = { + defaultValue?: T, + validate?: (value: T) => string | void +}; + +const noop = () => {}; + +export abstract class Setting { + // This is just to enable the `TypeOf` helper + readonly _type: V; + private readonly defaultValue: V | void; + private readonly validateResult: (value: V) => string | void; + + constructor(options: SettingOptions = {}) { + this.defaultValue = options.defaultValue; + this.validateResult = options.validate || noop; + } + + validate(value: any = this.defaultValue, context?: string): V { + const result = this.process(value, context); + + const validation = this.validateResult(result); + if (typeof validation === 'string') { + throw new SettingError(validation, context); + } + + return result; + } + + protected 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.validate(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; + } +} + +class LiteralSetting extends Setting { + + constructor(private readonly value: T) { + super(); + } + + process(value: any, context?: string): T { + if (value !== this.value) { + throw new SettingError( + `expected value to equal [${this.value}] but got [${value}]`, + context + ); + } + + return value; + } +} + +class UnionSetting, T> extends Setting { + + constructor(public readonly settings: RTS, options?: SettingOptions) { + super(options); + } + + process(value: any, context?: string): T { + let errors = []; + + for (const i in this.settings) { + try { + return this.settings[i].validate(value, toContext(context, i)) + } catch(e) { + errors.push(e); + } + } + + throw new SettingsError(errors, 'settings that failed validation', context); + } +} + +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 { + const type = typeDetect(value); + + // Do we want to allow strings that can be converted, e.g. "2"? (Joi does) + // (this can for example be nice in http endpoints with query params) + // + // From Joi docs on `Joi.number`: + // > Generates a schema object that matches a number data type (as well as + // > strings that can be converted to numbers) + if (typeof value === 'string') { + value = Number(value); + } + + if (typeof value !== 'number' || isNaN(value)) { + throw new SettingError( + `expected value of type [number] but got [${type}]`, + 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 DurationOptions = { + // we need to special-case defaultValue as we want to handle string inputs too + validate?: (value: Duration) => string | void + defaultValue?: Duration | string +}; + +function ensureDuration(value?: Duration | string) { + return typeof value === 'string' ? stringToDuration(value) : value; +} + +const timeFormatRegex = /^(0|[1-9][0-9]*)(ms|s|m|h|d|w|M|Y)$/; + +function stringToDuration(text: string) { + 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|M|Y] (e.g. '70ms', '5s', '3d', '1Y')` + ); + } + + const count = parseInt(result[1]); + const unit = result[2]; + + return momentDuration(count, unit); +} + +class DurationSetting extends Setting { + + constructor(options: DurationOptions = {}) { + const { defaultValue, ...rest } = options; + + super({ + ...rest, + defaultValue: ensureDuration(defaultValue) + }); + } + + process(value: any, context?: string): Duration { + if (typeof value === 'string') { + value = stringToDuration(value); + } + + if (!isDuration(value)) { + throw new SettingError( + `expected value of type [moment.Duration] but got [${typeDetect(value)}]`, + context + ); + } + + return value; + } +} + +type ArrayOptions = SettingOptions> & { + minSize?: number, + maxSize?: number +}; + +class ArraySetting extends Setting> { + private readonly itemSetting: Setting; + private readonly minSize?: number; + private readonly maxSize?: number; + + 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))); + } +} + +export type Props = Record; + +// 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 }> + +export class ObjectSetting

extends Setting> { + + constructor( + private readonly schema: P, + options: SettingOptions<{ [K in keyof P]: TypeOf }> = {} + ) { + super({ + ...options, + defaultValue: options.defaultValue + }); + } + + 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]; + 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 literal(value: T): Setting { + return new LiteralSetting(value); +} + +export function number(options?: NumberOptions): Setting { + return new NumberSetting(options); +} + +export function byteSize(options?: ByteSizeOptions): Setting { + return new ByteSizeSetting(options); +} + +export function duration(options?: DurationOptions): Setting { + return new DurationSetting(options); +} + +/** + * Create an optional setting + */ +export function maybe(setting: Setting): Setting { + 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); +} + +export function oneOf(types: [Setting, Setting, Setting, Setting], options?: SettingOptions): UnionSetting<[Setting, Setting, Setting, Setting], A | B | C | D> +export function oneOf(types: [Setting, Setting, Setting], options?: SettingOptions): UnionSetting<[Setting, Setting, Setting], A | B | C> +export function oneOf(types: [Setting, Setting], options?: SettingOptions): UnionSetting<[Setting, Setting], A | B> +export function oneOf(types: [Setting], options?: SettingOptions): UnionSetting<[Setting], A> +export function oneOf>(types: RTS, options?: SettingOptions): UnionSetting { + return new UnionSetting(types, options) +} diff --git a/platform/lib/topologicalSort/__tests__/__snapshots__/index.test.ts.snap b/platform/lib/topologicalSort/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000000000..a2041c4b6182fe --- /dev/null +++ b/platform/lib/topologicalSort/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws if ordering does not succeed 1`] = `"Topological ordering did not complete, these edges could not be ordered: [[\\"a\\",[\\"b\\"]],[\\"b\\",[\\"c\\"]],[\\"c\\",[\\"a\\"]],[\\"f\\",[\\"g\\"]]]"`; diff --git a/platform/lib/topologicalSort/__tests__/index.test.ts b/platform/lib/topologicalSort/__tests__/index.test.ts new file mode 100644 index 00000000000000..658e8625fd0d86 --- /dev/null +++ b/platform/lib/topologicalSort/__tests__/index.test.ts @@ -0,0 +1,46 @@ +import { topologicalSort } from '../'; + +test('returns a topologically ordered sequence', () => { + const nodes = new Map([ + ['a', []], + ['b', ['a']], + ['c', ['a', 'b']], + ['d', ['a']] + ]); + + let sorted = topologicalSort(nodes); + + expect(sorted).toBeDefined(); + + expect([...sorted!]).toEqual(['a', 'd', 'b', 'c']); +}); + +test('handles multiple "roots" with no deps', () => { + const nodes = new Map([ + ['a', []], + ['b', []], + ['c', ['a', 'b']], + ['d', ['a']] + ]); + + let sorted = topologicalSort(nodes); + + expect(sorted).toBeDefined(); + + expect([...sorted!]).toEqual(['b', 'a', 'd', 'c']); +}); + +test('throws if ordering does not succeed', () => { + const nodes = new Map([ + ['a', ['b']], + ['b', ['c']], + ['c', ['a', 'd']], // cycles back to 'a' + ['d', []], + ['e', ['d']], + ['f', ['g']] // 'g' does not 'exist' + ]); + + expect(() => { + topologicalSort(nodes); + }).toThrowErrorMatchingSnapshot(); +}); diff --git a/platform/lib/topologicalSort/index.ts b/platform/lib/topologicalSort/index.ts new file mode 100644 index 00000000000000..89ecb3caf7990e --- /dev/null +++ b/platform/lib/topologicalSort/index.ts @@ -0,0 +1,42 @@ +// Implementation of Kahn's Algorithm + +export function topologicalSort(graph: Map) { + // We clone the graph so we can mutate it while we perform the topological + // ordering. If the cloned graph is _not_ empty at the end, we know we were + // not able to topologically order the graph. + const clonedGraph = new Map(graph.entries()); + const sorted = new Set(); + + let noEdges = [...clonedGraph.keys()] + .filter(name => { + const edges = clonedGraph.get(name) as T[]; + return edges.length === 0; + }); + + while (noEdges.length > 0) { + const nodeName = noEdges.pop() as T; + // We know this node has no edges, so we can remove it + clonedGraph.delete(nodeName); + + sorted.add(nodeName); + + // Go through all nodes and remove all vertices into `nodeName` + [...clonedGraph.keys()].forEach(node => { + const edges = clonedGraph.get(node) as T[]; + const newEdges = edges.filter(vertex => vertex !== nodeName); + + clonedGraph.set(node, newEdges); + + if (newEdges.length === 0) { + noEdges.push(node); + } + }); + } + + if (clonedGraph.size > 0) { + const edgesLeft = JSON.stringify([...clonedGraph.entries()]); + throw new Error(`Topological ordering did not complete, these edges could not be ordered: ${edgesLeft}`); + } + + return sorted; +} diff --git a/platform/lib/utils/__tests__/__snapshots__/get.test.ts.snap b/platform/lib/utils/__tests__/__snapshots__/get.test.ts.snap new file mode 100644 index 00000000000000..f78726869b2d06 --- /dev/null +++ b/platform/lib/utils/__tests__/__snapshots__/get.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws if dot in string 1`] = `"Using dots in \`get\` with a string is not allowed, use array instead"`; diff --git a/platform/lib/utils/__tests__/get.test.ts b/platform/lib/utils/__tests__/get.test.ts new file mode 100644 index 00000000000000..b34fe5241bba66 --- /dev/null +++ b/platform/lib/utils/__tests__/get.test.ts @@ -0,0 +1,30 @@ +import { get } from '../get'; + +const obj = { + foo: 'value', + bar: { + quux: 123 + }, + 'dotted.value': 'dots' +} + +test('get with string', () => { + const value = get(obj, 'foo'); + expect(value).toBe('value'); +}); + +test('get with array', () => { + const value = get(obj, ['bar', 'quux']); + expect(value).toBe(123); +}); + +test('throws if dot in string', () => { + expect(() => { + get(obj, 'dotted.value'); + }).toThrowErrorMatchingSnapshot(); +}); + +test('does not throw if dot in array', () => { + const value = get(obj, ['dotted.value']); + expect(value).toBe('dots'); +}); diff --git a/platform/lib/utils/assertNever.ts b/platform/lib/utils/assertNever.ts new file mode 100644 index 00000000000000..b10fd5a7ce48aa --- /dev/null +++ b/platform/lib/utils/assertNever.ts @@ -0,0 +1,5 @@ +// Can be used in switch statements to ensure we perform exhaustive checks, see +// https://www.typescriptlang.org/docs/handbook/advanced-types.html#exhaustiveness-checking +export function assertNever(x: never): never { + throw new Error(`Unexpected object: ${x}`); +} diff --git a/platform/lib/utils/get.ts b/platform/lib/utils/get.ts new file mode 100644 index 00000000000000..093be1e1a13d9f --- /dev/null +++ b/platform/lib/utils/get.ts @@ -0,0 +1,29 @@ +type Obj = { [k: string]: T }; + +/** + * 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. + */ +export function get, A extends keyof CFG, B extends keyof CFG[A], C extends keyof CFG[A][B], D extends keyof CFG[A][B][C], E extends keyof CFG[A][B][C][D]>(obj: CFG, path: [A, B, C, D, E]): CFG[A][B][C][D][E]; +export function get, A extends keyof CFG, B extends keyof CFG[A], C extends keyof CFG[A][B], D extends keyof CFG[A][B][C]>(obj: CFG, path: [A, B, C, D]): CFG[A][B][C][D]; +export function get, A extends keyof CFG, B extends keyof CFG[A], C extends keyof CFG[A][B]>(obj: CFG, path: [A, B, C]): CFG[A][B][C]; +export function get, A extends keyof CFG, B extends keyof CFG[A]>(obj: CFG, path: [A, B]): CFG[A][B]; +export function get, A extends keyof CFG>(obj: CFG, path: [A]): CFG[A]; +export function get, A extends keyof CFG>(obj: CFG, path: A): CFG[A]; +export function get>(obj: CFG, path: Array | string): any { + if (typeof path === 'string') { + if (path.includes('.')) { + throw new Error('Using dots in `get` with a string is not allowed, use array instead'); + } + + return obj[path]; + } + + for (let key of path) { + obj = obj[key] + } + + return obj; +} diff --git a/platform/lib/utils/index.ts b/platform/lib/utils/index.ts new file mode 100644 index 00000000000000..7e79293a3051a5 --- /dev/null +++ b/platform/lib/utils/index.ts @@ -0,0 +1,3 @@ +export * from './get'; +export * from './pick'; +export * from './assertNever'; diff --git a/platform/lib/utils/pick.ts b/platform/lib/utils/pick.ts new file mode 100644 index 00000000000000..6bb2aaf337a898 --- /dev/null +++ b/platform/lib/utils/pick.ts @@ -0,0 +1,13 @@ +type Obj = { [k: string]: T }; + +export function pick< + T extends Obj, + K extends keyof T +>(obj: T, keys: K[]): Pick { + const newObj = keys.reduce((acc, val) => { + acc[val] = obj[val]; + return acc; + }, {} as Obj); + + return newObj as Pick; +} diff --git a/platform/logger/EventLogger/ConsoleLogger.ts b/platform/logger/EventLogger/ConsoleLogger.ts new file mode 100644 index 00000000000000..9251f965ec87a0 --- /dev/null +++ b/platform/logger/EventLogger/ConsoleLogger.ts @@ -0,0 +1,20 @@ +import { EventLogger, LogEvent } from './Log'; +import { LoggerConfig } from '../LoggerConfig'; + +export 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 console logger', config); + } + + close(): void { + console.log('close console logger'); + } +} \ No newline at end of file diff --git a/platform/logger/EventLogger/Log.ts b/platform/logger/EventLogger/Log.ts new file mode 100644 index 00000000000000..e4a327c47a23bb --- /dev/null +++ b/platform/logger/EventLogger/Log.ts @@ -0,0 +1,17 @@ +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; +} diff --git a/platform/logger/EventLogger/WinstonLogger.ts b/platform/logger/EventLogger/WinstonLogger.ts new file mode 100644 index 00000000000000..39db82eab055cd --- /dev/null +++ b/platform/logger/EventLogger/WinstonLogger.ts @@ -0,0 +1,27 @@ +import { EventLogger, LogEvent } from './Log'; +import { LoggerConfig } from '../LoggerConfig'; + +// TODO Weeeell, _actually_ use something like Winston :see_no_evil: + +export class WinstonLogger implements EventLogger { + constructor(config: LoggerConfig) { + console.log('creating winston logger'); + } + + log(event: LogEvent): void { + console.log( + `-`, + `[${event.level.id}]`, + `[${event.context.join('.')}]`, + event.message + ); + } + + update(config: LoggerConfig): void { + console.log('update winston logger', config); + } + + close(): void { + console.log('close winston logger'); + } +} \ No newline at end of file diff --git a/platform/logger/EventLogger/index.ts b/platform/logger/EventLogger/index.ts new file mode 100644 index 00000000000000..d451b0da8b8647 --- /dev/null +++ b/platform/logger/EventLogger/index.ts @@ -0,0 +1,13 @@ +import { EventLogger } from './Log'; +import { ConsoleLogger } from './ConsoleLogger'; +import { WinstonLogger } from './WinstonLogger'; +import { LoggerConfig } from '../LoggerConfig'; + +export { EventLogger }; + +// The default logger must be config free, as we don't have access to the +// config yet +export const defaultLogger: EventLogger = new ConsoleLogger(); + +export const createLoggerFromConfig = (config: LoggerConfig): EventLogger => + new WinstonLogger(config); diff --git a/platform/logger/Level.ts b/platform/logger/Level.ts new file mode 100644 index 00000000000000..9f51abb381afe1 --- /dev/null +++ b/platform/logger/Level.ts @@ -0,0 +1,19 @@ +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 00000000000000..0eabf53c723562 --- /dev/null +++ b/platform/logger/LoggerAdapter.ts @@ -0,0 +1,74 @@ +import { EventLogger } from './EventLogger'; +import { Level } from './Level'; + +// This is the logger interface that will be available. + +// TODO Should implement LoggerFactory so it's possible to `get` a "deeper +// logger" that builds on the context. +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 00000000000000..e1ffd0cf1573a5 --- /dev/null +++ b/platform/logger/LoggerConfig.ts @@ -0,0 +1,56 @@ +import { Level } from './Level'; +import { Schema, typeOfSchema } from '../types'; + +const createLoggerSchema = (schema: Schema) => + schema.object({ + dest: schema.string({ + defaultValue: 'stdout' + }), + silent: schema.boolean({ + defaultValue: false + }), + quiet: schema.boolean({ + defaultValue: false + }), + verbose: schema.boolean({ + defaultValue: false + }) + }); + +const loggingConfigType = typeOfSchema(createLoggerSchema); +type HttpConfigType = typeof loggingConfigType; + +export class LoggerConfig { + static createSchema = createLoggerSchema; + + readonly dest: string; + private readonly silent: boolean; + private readonly quiet: boolean; + private readonly verbose: boolean; + + constructor(config: HttpConfigType) { + 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`. + // ^ is what ES does, right? + 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 00000000000000..c97df766cd829f --- /dev/null +++ b/platform/logger/LoggerFactory.ts @@ -0,0 +1,78 @@ +import { Level } from './Level'; +import { LoggerConfig } from './LoggerConfig'; +import { Logger, LoggerAdapter } from './LoggerAdapter'; +import { createLoggerFromConfig, defaultLogger, EventLogger } from './EventLogger/index'; + +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 { + + private logger: EventLogger = defaultLogger; + private level: Level = Level.Debug; + + // 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) { + // TODO Check if type is different, then switch + if (this.logger === defaultLogger) { + this.logger.close(); + return createLoggerFromConfig(config); + } + + this.logger.update(config); + return this.logger; + } + + close(): void { + for (const key in this.loggerByContext) { + delete this.loggerByContext[key]; + } + + 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 00000000000000..a292580ab2d757 --- /dev/null +++ b/platform/logger/LoggerService.ts @@ -0,0 +1,32 @@ +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 { + private readonly stop$ = new Subject(); + + constructor(private readonly mutableLogger: MutableLogger) { + } + + upgrade(config$: Observable) { + config$ + .takeUntil(this.stop$) + .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 00000000000000..6c4cf62d54804e --- /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/__mocks__/index.ts b/platform/logger/__mocks__/index.ts new file mode 100644 index 00000000000000..24904f60f4aa8b --- /dev/null +++ b/platform/logger/__mocks__/index.ts @@ -0,0 +1,27 @@ +// Test helpers to simplify mocking logs and collecting all their outputs + +const _log = { + debug: jest.fn(), + info: jest.fn(), + error: jest.fn() +}; + +const _clear = () => { + logger.get.mockClear(); + _log.debug.mockClear(); + _log.info.mockClear(); + _log.error.mockClear(); +} + +const _collect = () => ({ + debug: _log.debug.mock.calls, + info: _log.info.mock.calls, + error: _log.error.mock.calls +}); + +export const logger = { + get: jest.fn(() => _log), + _log, + _collect, + _clear +}; \ No newline at end of file diff --git a/platform/logger/__tests__/LoggerAdapter.test.ts b/platform/logger/__tests__/LoggerAdapter.test.ts new file mode 100644 index 00000000000000..3c73902ea4d5be --- /dev/null +++ b/platform/logger/__tests__/LoggerAdapter.test.ts @@ -0,0 +1,117 @@ +import { LoggerAdapter } from '../LoggerAdapter'; +import { Level } from '../Level'; + +test('calls the provided logger', () => { + const logger = { + log: jest.fn(), + update: jest.fn(), + close: jest.fn() + }; + + const loggerAdapter = new LoggerAdapter( + ['name', 'space'], + logger, + Level.Info + ); + + loggerAdapter.info('message', { key: 'value' }); + + expect(logger.log).toHaveBeenCalledTimes(1); + + const callToLog = logger.log.mock.calls[0][0]; + + expect(callToLog).toHaveProperty('timestamp'); + + expect(callToLog).toMatchObject({ + context: ['name', 'space'], + level: { + id: 'info' + }, + message: 'message', + meta: { key: 'value' } + }); +}); + +test('calls the updated logger', () => { + const logger = { + log: jest.fn(), + update: jest.fn(), + close: jest.fn() + }; + + const loggerAdapter = new LoggerAdapter( + ['name', 'space'], + logger, + Level.Info + ); + + const newLogger = { + log: jest.fn(), + update: jest.fn(), + close: jest.fn() + } + + loggerAdapter.update(newLogger, Level.Info); + + loggerAdapter.info('message', { key: 'value' }); + + expect(logger.log).toHaveBeenCalledTimes(0); + expect(newLogger.log).toHaveBeenCalledTimes(1); +}); + +test('does not call logger if log level is not high enough', () => { + const logger = { + log: jest.fn(), + update: jest.fn(), + close: jest.fn() + }; + + const loggerAdapter = new LoggerAdapter( + ['name', 'space'], + logger, + Level.Info + ); + + loggerAdapter.debug('message', { key: 'value' }); + + expect(logger.log).toHaveBeenCalledTimes(0); +}); + +test('throws if logger or level is not specified', () => { + const loggerAdapter = new LoggerAdapter(['foo']); + + expect(() => { + loggerAdapter.debug('message', { key: 'value' }); + }).toThrowErrorMatchingSnapshot(); +}); + +test('can log errors', () => { + const logger = { + log: jest.fn(), + update: jest.fn(), + close: jest.fn() + }; + + const loggerAdapter = new LoggerAdapter( + ['name', 'space'], + logger, + Level.Info + ); + + const error = new Error('my error message'); + loggerAdapter.error(error); + + expect(logger.log).toHaveBeenCalledTimes(1); + + const callToLog = logger.log.mock.calls[0][0]; + + expect(callToLog).toMatchObject({ + context: ['name', 'space'], + level: { + id: 'error' + }, + error, + message: 'my error message', + meta: undefined + }); +}); diff --git a/platform/logger/__tests__/LoggerFactory.test.ts b/platform/logger/__tests__/LoggerFactory.test.ts new file mode 100644 index 00000000000000..64ae0187502884 --- /dev/null +++ b/platform/logger/__tests__/LoggerFactory.test.ts @@ -0,0 +1,25 @@ +const mockLoggerAdapter = jest.fn(); + +jest.mock('../LoggerAdapter', () => ({ + LoggerAdapter: mockLoggerAdapter +})) + +import { MutableLoggerFactory } from '../LoggerFactory'; + +test('returns instance of logger adapter at namespace', () => { + const factory = new MutableLoggerFactory(); + + const logger = factory.get('my', 'namespace'); + + expect(mockLoggerAdapter.mock.instances.length).toBe(1); + expect(mockLoggerAdapter.mock.instances[0]).toBe(logger); +}); + +test('returns same instance every time at namespace', () => { + const factory = new MutableLoggerFactory(); + + const logger = factory.get('my', 'namespace'); + const logger2 = factory.get('my', 'namespace'); + + expect(logger).toBe(logger2); +}); diff --git a/platform/logger/__tests__/LoggerService.test.ts b/platform/logger/__tests__/LoggerService.test.ts new file mode 100644 index 00000000000000..dc45f375c43829 --- /dev/null +++ b/platform/logger/__tests__/LoggerService.test.ts @@ -0,0 +1,74 @@ +import { BehaviorSubject } from 'rxjs'; + +import { LoggerService } from '../LoggerService'; +import { MutableLogger } from '../LoggerFactory'; +import { LoggerConfig } from '../LoggerConfig'; + +const config = new LoggerConfig({ + dest: 'test', + silent: false, + quiet: false, + verbose: false +}); + +const config2 = new LoggerConfig({ + dest: 'test2', + silent: true, + quiet: false, + verbose: false +}); + +test('updates mutable logger when receiving new logger configs', () => { + const mutableLogger: MutableLogger = { + updateLogger: jest.fn(), + close: jest.fn() + } + + const config$ = new BehaviorSubject(config); + + const loggerService = new LoggerService(mutableLogger); + + loggerService.upgrade(config$.asObservable()); + + expect(mutableLogger.updateLogger).toHaveBeenCalledTimes(1); + expect(mutableLogger.updateLogger).toHaveBeenLastCalledWith(config); + + config$.next(config2); + + expect(mutableLogger.updateLogger).toHaveBeenCalledTimes(2); + expect(mutableLogger.updateLogger).toHaveBeenLastCalledWith(config2); +}); + +test('closes mutable logger when stopped', () => { + const mutableLogger: MutableLogger = { + updateLogger: jest.fn(), + close: jest.fn() + } + + const config$ = new BehaviorSubject(config); + + const loggerService = new LoggerService(mutableLogger); + + loggerService.upgrade(config$.asObservable()); + loggerService.stop(); + + expect(mutableLogger.close).toHaveBeenCalledTimes(1); +}); + +test('does not update mutable logger after stopped', () => { + const mutableLogger: MutableLogger = { + updateLogger: jest.fn(), + close: jest.fn() + } + + const config$ = new BehaviorSubject(config); + + const loggerService = new LoggerService(mutableLogger); + + loggerService.upgrade(config$.asObservable()); + loggerService.stop(); + + config$.next(config2); + + expect(mutableLogger.updateLogger).toHaveBeenCalledTimes(1); +}); diff --git a/platform/logger/__tests__/__snapshots__/LoggerAdapter.test.ts.snap b/platform/logger/__tests__/__snapshots__/LoggerAdapter.test.ts.snap new file mode 100644 index 00000000000000..f8f27b453c9ba3 --- /dev/null +++ b/platform/logger/__tests__/__snapshots__/LoggerAdapter.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws if logger or level is not specified 1`] = `"Both logger and level must be specified. Logger was [undefined]. Log level was [undefined]."`; diff --git a/platform/logger/index.ts b/platform/logger/index.ts new file mode 100644 index 00000000000000..6ef0060f051e79 --- /dev/null +++ b/platform/logger/index.ts @@ -0,0 +1,15 @@ +export { LoggerService } from './LoggerService'; +export { LoggerFactory, MutableLoggerFactory } from './LoggerFactory'; +export { Logger } from './LoggerAdapter'; +export { LoggerConfig } from './LoggerConfig'; + +// TODO Move to README +// # 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. diff --git a/platform/plugins/pid/PidConfig.ts b/platform/plugins/pid/PidConfig.ts new file mode 100644 index 00000000000000..a4ce89c76d6e80 --- /dev/null +++ b/platform/plugins/pid/PidConfig.ts @@ -0,0 +1,25 @@ +import { Schema, typeOfSchema } from '../../types'; + +const createPidSchema = (schema: Schema) => + schema.object({ + file: schema.string(), + + // whether or not we should fail if pid file already exists + exclusive: schema.boolean({ + defaultValue: false + }) + }); + +const pidConfigType = typeOfSchema(createPidSchema); + +export class PidConfig { + static createSchema = createPidSchema; + + file: string; + failIfPidFileAlreadyExists: boolean; + + constructor(config: typeof pidConfigType) { + this.file = config.file; + this.failIfPidFileAlreadyExists = config.exclusive; + } +} diff --git a/platform/plugins/pid/PidService.ts b/platform/plugins/pid/PidService.ts new file mode 100644 index 00000000000000..654a90f076e219 --- /dev/null +++ b/platform/plugins/pid/PidService.ts @@ -0,0 +1,92 @@ +import { writeFileSync, unlinkSync } from 'fs'; +import { Observable, Subscription } from 'rxjs'; + +import { PidConfig } from './PidConfig'; +import { LoggerFactory, Logger } from '../../logger'; +import { KibanaError } from '../../lib/Errors'; + +const FILE_ALREADY_EXISTS = 'EEXIST'; + +const noop = () => {}; + +class PidFile { + log: Logger; + + constructor( + private readonly pid: number, + private readonly pidConfig: PidConfig, + logger: LoggerFactory + ) { + this.log = logger.get('pidfile'); + } + + writeFile() { + 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.failIfPidFileAlreadyExists) { + throw new KibanaError(message, err); + } + + this.log.warn(message, { path, pid }); + + writeFileSync(path, pid); + } + + this.log.debug(`wrote pid file [${path}]`); + } + + deleteFile() { + const path = this.pidConfig.file; + this.log.debug(`deleting pid file [${path}]`); + unlinkSync(path); + } +} + +export class PidService { + private readonly pid$: Observable; + private subscription?: Subscription; + + constructor(pidConfig$: Observable, logger: LoggerFactory) { + this.pid$ = pidConfig$ + .map(config => config !== undefined + ? new PidFile(process.pid, config, logger) + : undefined + ) + .switchMap(pid => { + // We specifically handle `undefined` to make sure the previous pid + // will be deleted. + if (pid === undefined) { + return new Observable(noop); + } + + return new Observable(observable => { + pid.writeFile(); + observable.next(pid); + + return () => { + pid.deleteFile(); + } + }) + }) + } + + start() { + this.subscription = this.pid$.subscribe(); + } + + stop() { + if (this.subscription !== undefined) { + this.subscription.unsubscribe(); + } + } +} \ No newline at end of file diff --git a/platform/plugins/pid/index.ts b/platform/plugins/pid/index.ts new file mode 100644 index 00000000000000..3b60a905509814 --- /dev/null +++ b/platform/plugins/pid/index.ts @@ -0,0 +1,30 @@ +import { KibanaPlugin } from '../../server/plugins/types'; +import { KibanaPluginFeatures } from '../../types'; +import { Logger } from '../../logger' + +import { PidConfig } from './PidConfig'; +import { PidService } from './PidService'; + +export const dependencies = []; + +export const plugin = class implements KibanaPlugin { + log: Logger; + pidService: PidService; + + constructor(kibana: KibanaPluginFeatures) { + this.log = kibana.logger.get(); + + const config$ = kibana.config.optionalAtPath('pid', PidConfig); + this.pidService = new PidService(config$, kibana.logger); + } + + start() { + this.log.info('starting PidService'); + this.pidService.start(); + } + + stop() { + this.log.info('stopping PidService'); + this.pidService.stop(); + } +} diff --git a/platform/plugins/reporting/ReportingConfig.ts b/platform/plugins/reporting/ReportingConfig.ts new file mode 100644 index 00000000000000..d9602ca98610c0 --- /dev/null +++ b/platform/plugins/reporting/ReportingConfig.ts @@ -0,0 +1,23 @@ +import { Schema, typeOfSchema } from '../../types'; + +const createReportingSchema = (schema: Schema) => + schema.object({ + enabled: schema.boolean({ + defaultValue: true + }), + encryptionKey: schema.string() + }); + +const reportingConfigType = typeOfSchema(createReportingSchema); + +export class ReportingConfig { + static createSchema = createReportingSchema; + + enabled: boolean; + encryptionKey: string; + + constructor(config: typeof reportingConfigType) { + this.enabled = config.enabled; + this.encryptionKey = config.encryptionKey; + } +} diff --git a/platform/plugins/reporting/index.ts b/platform/plugins/reporting/index.ts new file mode 100644 index 00000000000000..73b0500f5b2820 --- /dev/null +++ b/platform/plugins/reporting/index.ts @@ -0,0 +1,25 @@ +import { KibanaFunctionalPlugin } from '../../server/plugins/types'; +import { XPackPluginType } from '../xpack'; +import { ReportingConfig } from './ReportingConfig'; + +export const dependencies = ['xpack']; + +export const plugin: KibanaFunctionalPlugin = (kibana, dependencies) => { + const { xpack } = dependencies; + + const log = kibana.logger.get(); + + const config$ = kibana.config.optionalAtPath(['xpack', 'reporting'], ReportingConfig); + + // just an example + const isEnabled$ = config$ + .map(config => config && config.enabled); + + isEnabled$.subscribe(isEnabled => { + log.info(`reporting enabled? [${isEnabled}]`); + }); + + xpack.config$.subscribe(xpackConfig => { + log.info(`xpack polling frequency: [${xpackConfig.pollingFrequencyInMillis}]`); + }); +} diff --git a/platform/plugins/savedObjects/SavedObjectsFacade.ts b/platform/plugins/savedObjects/SavedObjectsFacade.ts new file mode 100644 index 00000000000000..18a79d78b65272 --- /dev/null +++ b/platform/plugins/savedObjects/SavedObjectsFacade.ts @@ -0,0 +1,75 @@ +import { Observable } from 'rxjs'; + +// TODO Change imports to some types file +import { ElasticsearchService } from '../../server/elasticsearch/ElasticsearchService'; +import { KibanaConfig } from '../../server/kibana'; +import { KibanaRequest } from '../../server/http'; + +type FindOptions = { + perPage?: number, + page?: number, + type?: string +} + +// Just a helper to extract the latest values from observables +function latestValues(a: Observable): Promise<[A]>; +function latestValues(a: Observable, b: Observable): Promise<[A, B]>; +function latestValues(a: Observable, b: Observable, c: Observable): Promise<[A, B, C]>; +function latestValues(a: Observable, b: Observable, c: Observable, d: Observable): Promise<[A, B, C, D]>; +function latestValues(...values: Observable[]) { + return Observable.combineLatest(values).first().toPromise(); +} + +export class SavedObjectsFacade { + + constructor( + private readonly req: KibanaRequest, + private readonly kibanaConfig$: Observable, + private readonly elasticsearchService: ElasticsearchService + ) {} + + async find( + options: FindOptions = {} + ) { + const { + page = 1, + perPage = 20, + type + } = options; + + const [ + kibanaIndex, + adminCluster + ] = await latestValues( + this.kibanaConfig$.map(config => config.index), + this.elasticsearchService.getClusterOfType$('admin') + ); + + const response = await adminCluster.withRequest( + this.req, + (client, headers) => + client.search({ + // headers, + // TODO ^ buggy elasticsearch.js typings! + index: kibanaIndex, + type, + size: perPage, + from: perPage * (page - 1) + }) + ); + + const data = response.hits.hits.map(hit => ({ + id: hit._id, + type: hit._type, + version: hit._version, + attributes: hit._source + })); + + return { + data, + total: response.hits.total, + per_page: perPage, + page: page + }; + } +} diff --git a/platform/plugins/savedObjects/index.ts b/platform/plugins/savedObjects/index.ts new file mode 100644 index 00000000000000..141537974398f2 --- /dev/null +++ b/platform/plugins/savedObjects/index.ts @@ -0,0 +1,22 @@ +import { KibanaFunctionalPlugin } from '../../server/plugins/types'; +import { SavedObjectsFacade } from './SavedObjectsFacade'; +import { registerEndpoints } from './registerEndpoints'; + +export const dependencies = []; + +export const plugin: KibanaFunctionalPlugin<{}> = kibana => { + const { kibana: k, elasticsearch, logger, util, http } = kibana; + + const log = logger.get(); + + log.info('creating savedObjects plugin'); + + const router = http.createAndRegisterRouter('/api/saved_objects', { + // TODO This should _NOT_ inject `req`, but rather the correct cluster + // preset with the request (so it sets headers etc). However, that requires + // changes to the `ElasticsearchClient`, it looks like. + onRequest: req => new SavedObjectsFacade(req, k.config$, elasticsearch.service) + }); + + registerEndpoints(router, logger, util.schema); +} diff --git a/platform/plugins/savedObjects/registerEndpoints.ts b/platform/plugins/savedObjects/registerEndpoints.ts new file mode 100644 index 00000000000000..d25ab987821e59 --- /dev/null +++ b/platform/plugins/savedObjects/registerEndpoints.ts @@ -0,0 +1,67 @@ +import { Router } from '../../server/http'; +import { LoggerFactory } from '../../logger' +import { Schema } from '../../types'; +import { SavedObjectsFacade } from './SavedObjectsFacade'; + +export function registerEndpoints( + router: Router, + logger: LoggerFactory, + schema: Schema +) { + const { object, string, number, oneOf, arrayOf, maybe } = schema; + const log = logger.get('routes'); + + router.get({ + path: '/fail' + }, async (savedObjectsFacade, req, res) => { + log.info(`GET should fail`); + + return res.badRequest(new Error('nope')); + }); + + router.get({ + path: '/:type', + validate: { + params: object({ + type: string() + }), + query: object({ + per_page: maybe(number({ + min: 0, + defaultValue: 20 + })), + page: maybe(number({ + min: 0, + defaultValue: 1 + })), + search: maybe(string()), + search_fields: maybe( + oneOf([ + string(), + arrayOf(string()) + ]) + ), + fields: maybe( + oneOf([ + string(), + arrayOf(string()) + ]) + ) + }) + } + }, async (savedObjectsFacade, req, res) => { + const { params, query } = req; + + log.info(`[GET] request received on [saved_objects] with type [${params.type}]`); + + const savedObjects = await savedObjectsFacade.find({ + perPage: query.per_page, + page: query.page, + type: params.type + }); + + return res.ok(savedObjects); + // if 200 OK we can simplify to just: + // return savedObjects; + }); +} \ No newline at end of file diff --git a/platform/plugins/timelion/index.ts b/platform/plugins/timelion/index.ts new file mode 100644 index 00000000000000..652db15cf4f80e --- /dev/null +++ b/platform/plugins/timelion/index.ts @@ -0,0 +1,33 @@ +import { KibanaFunctionalPlugin } from '../../server/plugins/types'; +import { XPackPluginType } from '../xpack'; + +export const dependencies = ['xpack']; + +type TimelionFunction = () => void; + +interface TimelionExports { + registerFunction: (pluginName: string, fn: TimelionFunction) => void; +} + +export interface TimelionPluginType { + timelion: TimelionExports +} + +export const plugin: KibanaFunctionalPlugin = (kibana, dependencies) => { + const { xpack } = dependencies; + + const log = kibana.logger.get(); + + xpack.config$.subscribe(config => { + log.debug(`polling frequency: ${config.pollingFrequencyInMillis}`); + }); + + const registerFunction = (pluginName: string, timelionFunction: TimelionFunction) => { + log.info(`received function from: ${pluginName}`); + timelionFunction(); + } + + return { + registerFunction + } +} diff --git a/platform/plugins/timelionPluginA/index.ts b/platform/plugins/timelionPluginA/index.ts new file mode 100644 index 00000000000000..4cbb75b52acdb4 --- /dev/null +++ b/platform/plugins/timelionPluginA/index.ts @@ -0,0 +1,18 @@ +import { KibanaFunctionalPlugin } from '../../server/plugins/types'; +import { TimelionPluginType } from '../timelion' +import { TimelionPluginBType } from '../timelionPluginB' + +export const dependencies = ['timelion', 'timelionPluginB']; + +export const plugin: KibanaFunctionalPlugin = (kibana, dependencies) => { + const { timelion, timelionPluginB } = dependencies; + + const log = kibana.logger.get(); + + timelion.registerFunction('timelionPluginA', () => { + log.info('called by timelion'); + }); + + log.debug(`timelionPluginB.myValue: ${timelionPluginB.myValue}`); + log.debug(`timelionPluginB.myFunc(): ${timelionPluginB.myFunc()}`); +} diff --git a/platform/plugins/timelionPluginB/index.ts b/platform/plugins/timelionPluginB/index.ts new file mode 100644 index 00000000000000..825e12ffd8467b --- /dev/null +++ b/platform/plugins/timelionPluginB/index.ts @@ -0,0 +1,30 @@ +import { KibanaFunctionalPlugin } from '../../server/plugins/types'; +import { TimelionPluginType } from '../timelion' + +interface TimelionPluginBExports { + myValue: string, + myFunc: () => string; +} + +export interface TimelionPluginBType { + timelionPluginB: TimelionPluginBExports +}; + +export const dependencies = ['timelion']; + +export const plugin: KibanaFunctionalPlugin = (kibana, dependencies) => { + const { timelion } = dependencies; + + const log = kibana.logger.get(); + + timelion.registerFunction('timelionPluginB', () => { + log.info('called by timelion'); + }); + + log.warn(`intentionally no access to xpack even if transitive dep: ${(dependencies as any).xpack}`) + + return { + myValue: 'test', + myFunc: () => Math.random() > 0.5 ? 'yes' : 'no' + } +} diff --git a/platform/plugins/xpack/XPackConfig.ts b/platform/plugins/xpack/XPackConfig.ts new file mode 100644 index 00000000000000..0b596f2d589e45 --- /dev/null +++ b/platform/plugins/xpack/XPackConfig.ts @@ -0,0 +1,27 @@ +import { Duration } from 'moment'; + +import { Schema, typeOfSchema } from '../../types'; + +const createXPackConfig = (schema: Schema) => + schema.object({ + enabled: schema.boolean({ + defaultValue: true + }), + xpack_api_polling_frequency_millis: schema.duration({ + defaultValue: '30001ms' + }) + }); + +const xpackConfigType = typeOfSchema(createXPackConfig); + +export class XPackConfig { + static createSchema = createXPackConfig; + + enabled: boolean; + pollingFrequencyInMillis: Duration; + + constructor(config: typeof xpackConfigType) { + this.enabled = config.enabled; + this.pollingFrequencyInMillis = config.xpack_api_polling_frequency_millis; + } +} diff --git a/platform/plugins/xpack/index.ts b/platform/plugins/xpack/index.ts new file mode 100644 index 00000000000000..c5828d68b21deb --- /dev/null +++ b/platform/plugins/xpack/index.ts @@ -0,0 +1,26 @@ +import { Observable } from 'rxjs'; + +import { KibanaFunctionalPlugin } from '../../server/plugins/types'; +import { XPackConfig } from './XPackConfig'; + +export const dependencies = []; + +interface XPackExports { + config$: Observable +} + +export interface XPackPluginType { + xpack: XPackExports +} + +export const plugin: KibanaFunctionalPlugin<{}, XPackExports> = kibana => { + const log = kibana.logger.get(); + + log.info('xpack is running'); + + const config$ = kibana.config.atPath(['xpack', 'xpack_main'], XPackConfig); + + return { + config$ + } +} diff --git a/platform/root/__tests__/index.test.ts b/platform/root/__tests__/index.test.ts new file mode 100644 index 00000000000000..b31f6fcda8fd75 --- /dev/null +++ b/platform/root/__tests__/index.test.ts @@ -0,0 +1,109 @@ +const loggerConfig = {}; + +const configService = { + start: jest.fn(), + stop: jest.fn(), + reloadConfig: jest.fn(), + atPath: jest.fn(() => loggerConfig) +}; + +const mockConfigService = jest.fn(() => configService); + +const server = { + start: jest.fn(), + stop: jest.fn() +}; +const mockServer = jest.fn(() => server); + +const loggerService = { + upgrade: jest.fn(), + stop: jest.fn() +}; + +const logger = { + get: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn() + })) +}; + +const mockMutableLoggerFactory = jest.fn(() => logger); + +const mockLoggerService = jest.fn(() => loggerService); + +jest.mock('../../config', () => ({ ConfigService: mockConfigService })); +jest.mock('../../server', () => ({ Server: mockServer })); +jest.mock('../../logger', () => ({ + LoggerService: mockLoggerService, + MutableLoggerFactory: mockMutableLoggerFactory +})); + +import { Root } from '../'; +import { Env } from '../../config/Env'; + +let oldExit = process.exit; + +beforeEach(() => { + process.exit = jest.fn(); +}); + +afterEach(() => { + process.exit = oldExit; +}); + +test('starts services on "start"', () => { + const env = new Env('.'); + const root = new Root({}, env); + + expect(configService.start).toHaveBeenCalledTimes(0); + expect(loggerService.upgrade).toHaveBeenCalledTimes(0); + expect(server.start).toHaveBeenCalledTimes(0); + + root.start(); + + expect(configService.start).toHaveBeenCalledTimes(1); + expect(loggerService.upgrade).toHaveBeenCalledTimes(1); + expect(loggerService.upgrade).toHaveBeenLastCalledWith(loggerConfig); + expect(server.start).toHaveBeenCalledTimes(1); +}); + +test('reloads config', () => { + const env = new Env('.'); + const root = new Root({}, env); + + expect(configService.reloadConfig).toHaveBeenCalledTimes(0); + + root.reloadConfig(); + + expect(configService.reloadConfig).toHaveBeenCalledTimes(1); +}); + +test('stops services on "shutdown"', () => { + const env = new Env('.'); + const root = new Root({}, env); + + root.start(); + + expect(configService.stop).toHaveBeenCalledTimes(0); + expect(loggerService.stop).toHaveBeenCalledTimes(0); + expect(server.stop).toHaveBeenCalledTimes(0); + + root.shutdown(); + + expect(configService.stop).toHaveBeenCalledTimes(1); + expect(loggerService.stop).toHaveBeenCalledTimes(1); + expect(server.stop).toHaveBeenCalledTimes(1); +}); + +test('exits process on "shutdown"', () => { + const env = new Env('.'); + const root = new Root({}, env); + + root.start(); + + expect(process.exit).toHaveBeenCalledTimes(0); + + root.shutdown(); + + expect(process.exit).toHaveBeenCalledTimes(1); +}); \ No newline at end of file diff --git a/platform/root/index.ts b/platform/root/index.ts new file mode 100644 index 00000000000000..9461acc8d82f26 --- /dev/null +++ b/platform/root/index.ts @@ -0,0 +1,66 @@ +import { Server } from '../server'; +import { ConfigService, Env } from '../config'; +import { LoggerService, Logger, LoggerFactory, LoggerConfig, MutableLoggerFactory } from '../logger'; + +/** + * Top-level entry point to kick off the app and start the Kibana server. + */ +export class Root { + configService: ConfigService; + server?: Server; + log: Logger; + logger: LoggerFactory; + loggerService: LoggerService; + + constructor( + argv: {[key: string]: any}, + env: Env + ) { + const loggerFactory = new MutableLoggerFactory(); + this.loggerService = new LoggerService(loggerFactory); + this.logger = loggerFactory; + + this.log = this.logger.get('root'); + this.configService = new ConfigService(argv, env, this.logger); + } + + async start() { + this.configService.start(); + + const loggingConfig$ = this.configService.atPath( + 'logging', + LoggerConfig + ); + + this.loggerService.upgrade(loggingConfig$); + + this.log.info('starting the server'); + + this.server = new Server(this.configService, this.logger); + + try { + await this.server.start(); + } catch(e) { + this.log.error(e); + this.shutdown(); + } + } + + reloadConfig() { + this.configService.reloadConfig(); + } + + // TODO Accept optional `Error` reason for shutdown? Then we can `exit(1)` + // instead of `exit(0)` if it's because of a failure. + shutdown() { + this.log.info('stopping Kibana'); + if (this.server !== undefined) { + this.server.stop(); + } + this.configService.stop(); + this.loggerService.stop(); + + // TODO Should this be moved to cli? + process.exit(0); + } +} diff --git a/platform/server/elasticsearch/Cluster.ts b/platform/server/elasticsearch/Cluster.ts new file mode 100644 index 00000000000000..d94f2c4e2095b8 --- /dev/null +++ b/platform/server/elasticsearch/Cluster.ts @@ -0,0 +1,71 @@ +import { Client } from 'elasticsearch'; + +import { ElasticsearchConfig } from './ElasticsearchConfig'; +import { KibanaRequest } from '../http'; +import { Logger, LoggerFactory } from '../../logger'; + +export class Cluster { + private readonly log: Logger; + private readonly client: Client; + private readonly noAuthClient: Client; + + constructor( + private readonly config: ElasticsearchConfig, + logger: LoggerFactory + ) { + this.log = logger.get('elasticsearch', 'client', config.clusterType); + + this.client = new Client(config.toElasticsearchClientConfig()); + this.noAuthClient = new Client(config.toElasticsearchClientConfig({ + shouldAuth: false + })); + + this.log.info('clients created'); + } + + close() { + // TODO The elasticsearch.js typings are buggy and are missing `close` + this.client.close(); + this.noAuthClient.close(); + + this.log.info('cluster client stopped'); + } + + // Attempt at typing the current `callWithRequest`, which is really difficult + // to type properly the way it is right now. + // TODO This should _return_ a client preset with the headers, but this + // requires changing Elasticsearch.js + async withRequest( + req: KibanaRequest, + cb: (client: Client, headers: { [key: string]: any }) => T, + options: { + wrap401Errors: boolean + } = { + wrap401Errors: false + } + ) { + const headers = req.getFilteredHeaders(this.config.requestHeadersWhitelist); + + try { + return await cb(this.client, headers); + } catch (err) { + // instanceof err check? + + if (options.wrap401Errors && err.statusCode === 401) { + console.log('401 + wrap', err) + throw err; + + // From current Kibana: + + // const boomError = Boom.wrap(err, err.statusCode); + // const wwwAuthHeader = get(err, 'body.error.header[WWW-Authenticate]'); + // boomError.output.headers['WWW-Authenticate'] = wwwAuthHeader || 'Basic realm="Authorization Required"'; + + // throw boomError; + } + + console.log('request failed yo', err); + throw err; + } + } +} diff --git a/platform/server/elasticsearch/ElasticsearchConfig.ts b/platform/server/elasticsearch/ElasticsearchConfig.ts new file mode 100644 index 00000000000000..41af9882fd0e5c --- /dev/null +++ b/platform/server/elasticsearch/ElasticsearchConfig.ts @@ -0,0 +1,91 @@ +import * as url from 'url'; +import { ConfigOptions } from 'elasticsearch'; +import { readFileSync } from 'fs'; +import { noop } from 'lodash'; + +import { ClusterSchema } from './schema'; +import { pick, assertNever } from '../../lib/utils'; +import { ElasticsearchClusterType } from '../../types'; + +export class ElasticsearchConfig { + requestHeadersWhitelist: string[]; + + constructor(readonly clusterType: ElasticsearchClusterType, private readonly config: ClusterSchema) { + this.requestHeadersWhitelist = config.requestHeadersWhitelist + } + + /** + * Config for Elasticsearch client, e.g. + * + * ``` + * new elasticsearch.Client(config.toElasticsearchClientConfig()) + * ``` + * + * @param shouldAuth Whether or not to the config should include the username + * and password. Used to create a client that is not + * authenticated using the config, but from each request. + */ + toElasticsearchClientConfig({ shouldAuth = true } = {}): ConfigOptions { + const config: ConfigOptions = pick(this.config, + ['apiVersion', 'username', 'logQueries']); + + config.pingTimeout = this.config.pingTimeout.asMilliseconds(); + config.requestTimeout = this.config.requestTimeout.asMilliseconds(); + + config.keepAlive = true; + + const uri = url.parse(this.config.url); + + config.host = { + host: uri.hostname, + port: uri.port, + protocol: uri.protocol, + path: uri.pathname, + query: uri.query, + headers: this.config.customHeaders + } + + if ( + shouldAuth && + this.config.username !== undefined && + this.config.password !== undefined + ) { + config.host.auth = `${this.config.username}:${this.config.password}`; + } + + if (this.config.ssl !== undefined) { + const ssl: { [key: string]: any } = { + cert: readFileSync(this.config.ssl.certificate), + key: readFileSync(this.config.ssl.key), + passphrase: this.config.ssl.keyPassphrase + }; + + const verificationMode = this.config.ssl.verificationMode; + + switch (verificationMode) { + case 'none': + ssl.rejectUnauthorized = false; + break; + case 'certificate': + ssl.rejectUnauthorized = true; + + // by default NodeJS checks the server identify + ssl.checkServerIdentity = noop; + break; + case 'full': + ssl.rejectUnauthorized = true; + break; + default: + assertNever(verificationMode); + } + + if (this.config.ssl.certificateAuthorities.length > 0) { + ssl.ca = this.config.ssl.certificateAuthorities.map(readFileSync); + } + + config.ssl = ssl; + } + + return config; + } +} diff --git a/platform/server/elasticsearch/ElasticsearchConfigs.ts b/platform/server/elasticsearch/ElasticsearchConfigs.ts new file mode 100644 index 00000000000000..5061e33f2be0ba --- /dev/null +++ b/platform/server/elasticsearch/ElasticsearchConfigs.ts @@ -0,0 +1,25 @@ +import { ElasticsearchConfig } from './ElasticsearchConfig'; +import { createElasticsearchSchema, ElasticsearchConfigsSchema } from './schema'; +import { ElasticsearchClusterType } from '../../types'; +import { Env } from '../../config'; + +export class ElasticsearchConfigs { + static createSchema = createElasticsearchSchema; + + private readonly configs: { + [type in ElasticsearchClusterType]: ElasticsearchConfig + } + + constructor(config: ElasticsearchConfigsSchema, env: Env) { + this.configs = { + data: config.tribe !== undefined + ? new ElasticsearchConfig('data', config.tribe) + : new ElasticsearchConfig('data', config), + admin: new ElasticsearchConfig('admin', config) + } + } + + forType(type: ElasticsearchClusterType) { + return this.configs[type]; + } +} diff --git a/platform/server/elasticsearch/ElasticsearchFacade.ts b/platform/server/elasticsearch/ElasticsearchFacade.ts new file mode 100644 index 00000000000000..7e9f5faba46514 --- /dev/null +++ b/platform/server/elasticsearch/ElasticsearchFacade.ts @@ -0,0 +1,13 @@ +import { ElasticsearchService } from './ElasticsearchService'; +import { ElasticsearchClusterType } from '../../types'; + +export class ElasticsearchRequestHelpers { + constructor(private readonly elasticsearchService: ElasticsearchService) { + } + + getClusterOfType(type: ElasticsearchClusterType) { + return this.elasticsearchService.getClusterOfType$(type) + .first() + .toPromise(); + } +} diff --git a/platform/server/elasticsearch/ElasticsearchService.ts b/platform/server/elasticsearch/ElasticsearchService.ts new file mode 100644 index 00000000000000..520222d2dce7e9 --- /dev/null +++ b/platform/server/elasticsearch/ElasticsearchService.ts @@ -0,0 +1,70 @@ +import { Observable, Subscription } from 'rxjs'; + +import { ElasticsearchConfigs } from './ElasticsearchConfigs'; +import { Cluster } from './Cluster'; +import { LoggerFactory } from '../../logger'; +import { ElasticsearchClusterType } from '../../types'; + +type Clusters = { + [type in ElasticsearchClusterType]: Cluster +} + +export class ElasticsearchService { + private clusters$: Observable; + private subscription: Subscription; + + constructor( + config$: Observable, + logger: LoggerFactory + ) { + const log = logger.get('elasticsearch'); + + this.clusters$ = config$ + .filter(() => { + if (this.subscription !== undefined) { + log.error('clusters cannot be changed after they are created') + return false; + } + + return true; + }) + .switchMap(configs => + new Observable(observer => { + log.info('creating Elasticsearch clusters'); + + const clusters = { + data: new Cluster(configs.forType('data'), logger), + admin: new Cluster(configs.forType('admin'), logger) + }; + + observer.next(clusters); + + return () => { + log.info('closing Elasticsearch clusters'); + + clusters.data.close(); + clusters.admin.close(); + }; + }) + ) + // We only want a single subscription of this as we only want to create a + // single set of clusters at a time. We therefore share these, plus we + // always replay the latest set of clusters when subscribing. + .shareReplay(1); + } + + start() { + // ensure that we don't unnecessarily re-create clusters by always having + // at least one current connection + this.subscription = this.clusters$.subscribe(); + } + + stop() { + this.subscription.unsubscribe(); + } + + getClusterOfType$(type: ElasticsearchClusterType) { + return this.clusters$ + .map(clusters => clusters[type]) + } +} diff --git a/platform/server/elasticsearch/api.ts b/platform/server/elasticsearch/api.ts new file mode 100644 index 00000000000000..b160286a826ab4 --- /dev/null +++ b/platform/server/elasticsearch/api.ts @@ -0,0 +1,48 @@ +import { Router } from '../http'; +import { object, string, maybe } from '../../lib/schema'; +import { ElasticsearchRequestHelpers } from './ElasticsearchFacade'; +import { LoggerFactory } from '../../logger'; + +export function registerElasticsearchRoutes( + router: Router, + logger: LoggerFactory +) { + const log = logger.get('elasticsearch', 'routes'); + + log.info('creating elasticsearch api'); + + router.get({ + path: '/:field', + validate: { + params: object({ + field: string() + }), + query: object({ + key: maybe(string()) + }) + } + }, async (elasticsearch, req) => { + // WOHO! Both of these are typed! + log.info(`field param: ${req.params.field}`); + log.info(`query param: ${req.query.key}`); + + log.info('request received on [data] cluster'); + + const cluster = await elasticsearch.getClusterOfType('data'); + + log.info('got [data] cluster, now calling it'); + + const response = await cluster.withRequest( + req, + (client, headers) => client.search({}) + ); + + return { + params: req.params, + query: req.query, + total_count: response.hits.total + }; + }); + + return router; +} diff --git a/platform/server/elasticsearch/index.ts b/platform/server/elasticsearch/index.ts new file mode 100644 index 00000000000000..0bc21c64fc1d5c --- /dev/null +++ b/platform/server/elasticsearch/index.ts @@ -0,0 +1,29 @@ +import { Observable } from 'rxjs'; + +import { ElasticsearchService } from './ElasticsearchService'; +import { ElasticsearchRequestHelpers } from './ElasticsearchFacade'; +import { registerElasticsearchRoutes } from './api'; +import { Router } from '../http'; +import { ElasticsearchConfigs } from './ElasticsearchConfigs'; +import { LoggerFactory } from '../../logger'; + +export { ElasticsearchService, ElasticsearchRequestHelpers, ElasticsearchConfigs }; + +export class ElasticsearchModule { + readonly service: ElasticsearchService; + + constructor( + readonly config$: Observable, + private readonly logger: LoggerFactory + ) { + this.service = new ElasticsearchService(this.config$, logger); + } + + createRoutes() { + const router = new Router('/elasticsearch', { + onRequest: req => new ElasticsearchRequestHelpers(this.service) + }); + + return registerElasticsearchRoutes(router, this.logger); + } +} diff --git a/platform/server/elasticsearch/schema.ts b/platform/server/elasticsearch/schema.ts new file mode 100644 index 00000000000000..24abca20995780 --- /dev/null +++ b/platform/server/elasticsearch/schema.ts @@ -0,0 +1,63 @@ +import { Schema, typeOfSchema } from '../../types'; + +export const createSslSchema = (schema: Schema) => + schema.object({ + verificationMode: schema.oneOf([ + schema.literal('none'), + schema.literal('certificate'), + schema.literal('full') + ]), + certificateAuthorities: schema.arrayOf(schema.string(), { + minSize: 1 + }), + certificate: schema.string(), + key: schema.string(), + keyPassphrase: schema.string() + }) + +const DEFAULT_REQUEST_HEADERS = ['authorization']; + +const createSharedFields = (schema: Schema) => ({ + url: schema.string({ defaultValue: 'http://localhost:9200' }), + preserveHost: schema.boolean({ defaultValue: true }), + username: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + customHeaders: schema.maybe(schema.object({})), + requestHeadersWhitelist: schema.arrayOf(schema.string(), { + defaultValue: DEFAULT_REQUEST_HEADERS + }), + shardTimeout: schema.duration({ defaultValue: '30s' }), + requestTimeout: schema.duration({ defaultValue: '30s' }), + pingTimeout: schema.duration({ defaultValue: '30s' }), + startupTimeout: schema.duration({ defaultValue: '5s' }), + logQueries: schema.boolean({ defaultValue: false }), + apiVersion: schema.string({ defaultValue: 'master' }), + ssl: schema.maybe(createSslSchema(schema)) +}) + +const clusterSchema = (schema: Schema) => + schema.object({ + ...createSharedFields(schema) + }) + +export const createTribeSchema = (schema: Schema) => + schema.object({ + ...createSharedFields(schema) + }); + +export const createElasticsearchSchema = (schema: Schema) => + schema.object({ + enabled: schema.boolean({ defaultValue: true }), + ...createSharedFields(schema), + healthCheck: schema.object({ + delay: schema.duration({ defaultValue: '2500ms' }) + }), + tribe: schema.maybe(createTribeSchema(schema)) + }); + +const elasticsearchConfigType = typeOfSchema(createElasticsearchSchema); + +export type ElasticsearchConfigsSchema = typeof elasticsearchConfigType; + +const clusterConfigType = typeOfSchema(clusterSchema); +export type ClusterSchema = typeof clusterConfigType; diff --git a/platform/server/http/HttpConfig.ts b/platform/server/http/HttpConfig.ts new file mode 100644 index 00000000000000..539875ce463fda --- /dev/null +++ b/platform/server/http/HttpConfig.ts @@ -0,0 +1,58 @@ +import { SslConfig } from './SslConfig'; +import { Env } from '../../config'; +import { ByteSizeValue } from '../../lib/ByteSizeValue'; +import { Schema, typeOfSchema } from '../../types'; + +const validHostnameRegex = /^(([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 validBasePathRegex = /(^$|^\/.*[^\/]$)/; + +const match = (regex: RegExp, errorMsg: string) => + (str: string) => regex.test(str) ? undefined : errorMsg; + +const createHttpSchema = (schema: Schema) => { + const { object, string, number, byteSize, maybe } = schema; + + return object({ + host: string({ + defaultValue: 'localhost', + validate: match(validHostnameRegex, 'must be a valid hostname') + }), + port: number({ + defaultValue: 5601 + }), + maxPayload: byteSize({ + defaultValue: '1mb' + }), + basePath: maybe( + string({ + validate: match( + validBasePathRegex, + 'must start with a slash, don\'t end with one' + ) + }) + ), + ssl: SslConfig.createSchema(schema) + }); +} + +const httpConfigType = typeOfSchema(createHttpSchema); + +export class HttpConfig { + static createSchema = createHttpSchema; + + host: string; + port: number; + maxPayload: ByteSizeValue; + basePath?: string; + publicDir: string; + ssl: SslConfig; + + constructor(config: typeof httpConfigType, 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/HttpServer.ts b/platform/server/http/HttpServer.ts new file mode 100644 index 00000000000000..55e9bc0edd1305 --- /dev/null +++ b/platform/server/http/HttpServer.ts @@ -0,0 +1,37 @@ +import * as express from 'express'; +import * as http from 'http'; +import { Router } from './Router'; + +export class HttpServer { + private readonly app: express.Application; + private readonly httpServer: http.Server; + + constructor() { + this.app = express(); + this.httpServer = http.createServer(this.app); + } + + isListening() { + return this.httpServer.listening; + } + + registerRouter(router: Router) { + this.app.use(router.path, router.router); + } + + start(port: number, host: string) { + return new Promise((resolve, reject) => { + this.httpServer.listen(port, host, (err?: Error) => { + if (err != null) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + stop() { + this.httpServer.close(); + } +} diff --git a/platform/server/http/HttpService.ts b/platform/server/http/HttpService.ts new file mode 100644 index 00000000000000..010e8470674950 --- /dev/null +++ b/platform/server/http/HttpService.ts @@ -0,0 +1,83 @@ +import { Observable, Subscription } from 'rxjs'; + +import { HttpServer } from './HttpServer'; +import { HttpConfig } from './HttpConfig'; +import { Logger, LoggerFactory } from '../../logger'; +import { Router } from './Router'; + +export class HttpService { + private readonly httpServer: HttpServer; + private configSubscription?: Subscription; + + private readonly log: Logger; + + constructor( + private readonly config$: Observable, + logger: LoggerFactory + ) { + this.log = logger.get('http'); + this.httpServer = new HttpServer(); + } + + start() { + this.configSubscription = this.config$ + .filter(config => { + if (this.httpServer.isListening()) { + // 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. + this.log.error( + 'Received new HTTP config after server was started. ' + + 'Config will **not** be applied.' + ) + return false; + } + + return true; + }) + .switchMap(config => + new Observable(() => { + this.startHttpServer(config); + + return () => { + // TODO: This is async! :/ + this.stopHttpServer() + } + }) + ) + .subscribe() + } + + stop(): void { + if (this.configSubscription !== undefined) { + this.configSubscription.unsubscribe(); + } + } + + registerRouter(router: Router): void { + if (this.httpServer.isListening()) { + // 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. + // TODO Should we throw instead? + this.log.error( + `Received new router [${router.path}] after server was started. ` + + 'Router will **not** be applied.' + ) + } else { + this.log.info(`registering route handler for [${router.path}]`); + this.httpServer.registerRouter(router); + } + } + + private async startHttpServer(config: HttpConfig) { + const { host, port } = config; + + this.log.info(`starting http server [${host}:${port}]`); + + await this.httpServer.start(port, host); + } + + private stopHttpServer() { + this.log.debug('closing http server'); + this.httpServer.stop(); + } +} diff --git a/platform/server/http/Router/headers.ts b/platform/server/http/Router/headers.ts new file mode 100644 index 00000000000000..6d2ff71a91703e --- /dev/null +++ b/platform/server/http/Router/headers.ts @@ -0,0 +1,18 @@ +import { pick } from '../../../lib/utils'; + +export interface Headers { + [key: string]: string +}; + +const normalizeHeaderField = (field: string) => + field.trim().toLowerCase(); + +export function filterHeaders( + headers: Headers, + fieldsToKeep: string[] +) { + // Normalize list of headers we want to allow in upstream request + const fieldsToKeepNormalized = fieldsToKeep.map(normalizeHeaderField); + + return pick(headers, fieldsToKeepNormalized); +} \ No newline at end of file diff --git a/platform/server/http/Router/index.ts b/platform/server/http/Router/index.ts new file mode 100644 index 00000000000000..937497cfdfbb76 --- /dev/null +++ b/platform/server/http/Router/index.ts @@ -0,0 +1,188 @@ +import * as express from 'express'; + +import { Headers, filterHeaders } from './headers'; +import { ObjectSetting, Props, Any, TypeOf } from '../../../lib/schema'; + +interface Route< + Params extends ObjectSetting<{}>, + Query extends ObjectSetting<{}> +> { + path: string, + validate?: { + params?: Params, + query?: Query + } +} + +type Obj = { [key: string]: T }; + +interface ResponseFactory { + ok>(payload: T): KibanaResponse; + accepted>(payload: T): KibanaResponse; + noContent(): KibanaResponse; + badRequest(err: T): KibanaResponse +} + +const responseFactory: ResponseFactory = { + ok: >(payload: T) => new KibanaResponse(200, payload), + accepted: >(payload: T) => new KibanaResponse(202, payload), + noContent: () => new KibanaResponse(204), + badRequest: (err: T) => new KibanaResponse(400, err) +}; + +type RequestHandler< + RequestValue, + Params extends Any, + Query extends Any +> = ( + onRequestValue: RequestValue, + req: KibanaRequest, TypeOf>, + createResponse: ResponseFactory +) => Promise | Obj>; + +// TODO Needs _some_ work +type StatusCode = 200 | 202 | 204 | 400; + +class KibanaResponse { + constructor( + readonly status: StatusCode, + readonly payload?: T + ) {} +} + +// TODO Explore validating headers too (can't validate _all_, but you only +// receive the headers you _have_ validated). +export class KibanaRequest< + Params extends Props = {}, + Query extends Props = {} +> { + readonly headers: Headers; + + static validate< + Params extends Props = {}, + Query extends Props = {} + >( + route: Route, ObjectSetting>, + req: express.Request + ): { params: Params, query: Query } { + let params: Params; + let query: Query; + + if (route.validate === undefined) { + params = req.params; + query = req.query; + } else { + if (route.validate.params === undefined) { + params = req.params; + } else { + params = route.validate.params.validate(req.params); + } + + if (route.validate.query === undefined) { + query = req.query; + } else { + query = route.validate.query.validate(req.query); + } + } + + return { query, params }; + } + + constructor( + req: express.Request, + readonly params: Params, + readonly query: Query + ) { + this.headers = req.headers; + } + + getFilteredHeaders(headersToKeep: string[]) { + return filterHeaders(this.headers, headersToKeep); + } +} + +export interface RouterOptions { + onRequest?: (req: KibanaRequest) => T +} + +export class Router { + readonly router = express.Router(); + + constructor( + readonly path: string, + readonly options: RouterOptions = {} + ) {} + + get

, Q extends ObjectSetting>( + route: Route, + handler: RequestHandler + ) { + this.router.get(route.path, async (req, res) => { + let valid: { params: TypeOf

, query: TypeOf

}; + + // TODO Change this so we can get failures per type + try { + valid = KibanaRequest.validate(route, req); + } catch(e) { + res.status(400); + res.json({ error: e.message }); + return; + } + + const kibanaRequest = new KibanaRequest(req, valid.params, valid.query); + + const value = this.options.onRequest !== undefined + ? this.options.onRequest(kibanaRequest) + : {} as V; + + try { + const response = await handler( + value, + kibanaRequest, + responseFactory + ); + + if (response instanceof KibanaResponse) { + res.status(response.status); + + if (response.payload === undefined) { + res.send(); + } else if (response.payload instanceof Error) { + // TODO Design an error format + res.json({ error: response.payload.message }); + } else { + res.json(response.payload); + } + } else { + res.json(response); + } + } catch (e) { + // TODO Specifically handle `KibanaResponseError` and validation errors. + + // Otherwise we default to something along the lines of + res.status(500).json({ error: e.message }); + } + }); + } + + post

, Q extends ObjectSetting>( + route: Route, + handler: RequestHandler + ) { + // TODO + } + + put

, Q extends ObjectSetting>( + route: Route, + handler: RequestHandler + ) { + // TODO + } + + delete

, Q extends ObjectSetting>( + route: Route, + handler: RequestHandler + ) { + // TODO + } +} diff --git a/platform/server/http/SslConfig.ts b/platform/server/http/SslConfig.ts new file mode 100644 index 00000000000000..d5b8ae3a20d839 --- /dev/null +++ b/platform/server/http/SslConfig.ts @@ -0,0 +1,50 @@ +import * as crypto from 'crypto'; +import { has } from 'lodash'; + +import { Schema, typeOfSchema } from '../../types'; + +const createSslSchema = (schema: Schema) => { + const { object, boolean, string, arrayOf, oneOf, literal, maybe } = schema; + + return object({ + enabled: boolean({ + defaultValue: false + }), + certificate: maybe(string()), + key: maybe(string()), + keyPassphrase: maybe(string()), + certificateAuthorities: maybe(arrayOf(string())), + supportedProtocols: maybe( + arrayOf( + oneOf([ + literal('TLSv1'), + literal('TLSv1.1'), + literal('TLSv1.2') + ]) + ) + ), + 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'; + } + } + }) +} + +const sslConfigType = typeOfSchema(createSslSchema); + +export class SslConfig { + static createSchema = createSslSchema; + + enabled: boolean; + + constructor(config: typeof sslConfigType) { + this.enabled = config.enabled; + } +} \ No newline at end of file diff --git a/platform/server/http/__tests__/HttpConfig.test.ts b/platform/server/http/__tests__/HttpConfig.test.ts new file mode 100644 index 00000000000000..7c90b0a13185cf --- /dev/null +++ b/platform/server/http/__tests__/HttpConfig.test.ts @@ -0,0 +1,41 @@ +import * as schema from '../../../lib/schema'; +import { HttpConfig } from '../HttpConfig'; + +test('has defaults for config', () => { + const httpSchema = HttpConfig.createSchema(schema); + const obj = {}; + expect(httpSchema.validate(obj)).toMatchSnapshot(); +}); + +test('throws if invalid hostname', () => { + const httpSchema = HttpConfig.createSchema(schema); + const obj = { + host: 'asdf$%^' + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); +}); + +test('can specify max payload as string', () => { + const httpSchema = HttpConfig.createSchema(schema); + const obj = { + maxPayload: '2mb' + }; + const config = httpSchema.validate(obj); + expect(config.maxPayload.getValueInBytes()).toBe(2 * 1024 * 1024); +}); + +test('throws is basepath is missing prepended slash', () => { + const httpSchema = HttpConfig.createSchema(schema); + const obj = { + basePath: 'foo' + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); +}); + +test('throws is basepath appends a slash', () => { + const httpSchema = HttpConfig.createSchema(schema); + const obj = { + basePath: '/foo/' + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); +}); diff --git a/platform/server/http/__tests__/HttpServer.test.ts b/platform/server/http/__tests__/HttpServer.test.ts new file mode 100644 index 00000000000000..9efc550d9ec230 --- /dev/null +++ b/platform/server/http/__tests__/HttpServer.test.ts @@ -0,0 +1,291 @@ +import * as supertest from 'supertest'; +import * as Chance from 'chance'; + +import { Router } from '../Router'; +import { HttpServer } from '../HttpServer'; +import * as schema from '../../../lib/schema'; + +const chance = new Chance(); + +let server: HttpServer; +let app: any; +let port: number; + +beforeEach(() => { + port = chance.integer({ min: 10000, max: 15000 }); + server = new HttpServer(); + app = (server as any).httpServer; +}); + +afterEach(() => { + server && server.stop(); +}); + +test('listening after started', async () => { + expect(server.isListening()).toBe(false); + + await server.start(port, '127.0.0.1'); + + expect(server.isListening()).toBe(true); +}); + +test('200 OK with body', async () => { + const router = new Router('/foo'); + + router.get({ path: '/' }, async (val, req, res) => { + return res.ok({ key: 'value' }); + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app) + .get('/foo') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'value' }); + }); +}); + +test('202 Accepted with body', async () => { + const router = new Router('/foo'); + + router.get({ path: '/' }, async (val, req, res) => { + return res.accepted({ location: 'somewhere' }); + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app) + .get('/foo') + .expect(202) + .then(res => { + expect(res.body).toEqual({ location: 'somewhere' }); + }); +}); + +test('204 No content', async () => { + const router = new Router('/foo'); + + router.get({ path: '/' }, async (val, req, res) => { + return res.noContent(); + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app) + .get('/foo') + .expect(204) + .then(res => { + expect(res.body).toEqual({}); + // TODO Is ^ wrong or just a result of supertest, I expect `null` or `undefined` + }); +}); + +test('400 Bad request with error', async () => { + const router = new Router('/foo'); + + router.get({ path: '/' }, async (val, req, res) => { + const err = new Error('some message') + return res.badRequest(err); + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app) + .get('/foo') + .expect(400) + .then(res => { + expect(res.body).toEqual({ error: 'some message' }); + }); +}); + +test('valid params', async () => { + const router = new Router('/foo'); + + router.get({ + path: '/:test', + validate: { + params: schema.object({ + test: schema.string() + }) + } + }, async (val, req, res) => { + return res.ok({ key: req.params.test }); + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app) + .get('/foo/some-string') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'some-string' }); + }); +}); + +test('invalid params', async () => { + const router = new Router('/foo'); + + router.get({ + path: '/:test', + validate: { + params: schema.object({ + test: schema.number() + }) + } + }, async (val, req, res) => { + return res.ok({ key: req.params.test }); + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app) + .get('/foo/some-string') + .expect(400) + .then(res => { + expect(res.body).toEqual({ error: '[test]: expected value of type [number] but got [string]' }); + }); +}); + +test('valid query', async () => { + const router = new Router('/foo'); + + router.get({ + path: '/', + validate: { + query: schema.object({ + bar: schema.string(), + quux: schema.number() + }) + } + }, async (val, req, res) => { + return res.ok(req.query); + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app) + .get('/foo?bar=test&quux=123') + .expect(200) + .then(res => { + expect(res.body).toEqual({ bar: 'test', quux: 123 }); + }); +}); + +test('invalid query', async () => { + const router = new Router('/foo'); + + router.get({ + path: '/', + validate: { + query: schema.object({ + bar: schema.number() + }) + } + }, async (val, req, res) => { + return res.ok(req.query); + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app) + .get('/foo?bar=test') + .expect(400) + .then(res => { + expect(res.body).toEqual({ error: '[bar]: expected value of type [number] but got [string]' }); + }); +}); + +test('returns 200 OK if returning object', async () => { + const router = new Router('/foo'); + + router.get({ path: '/' }, async (val, req, res) => { + return { key: 'value' }; + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app) + .get('/foo') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'value' }); + }); +}); + +test('returns result from `onRequest` handler as first param in route handler', async () => { + expect.assertions(1); + + const router = new Router('/foo', { + onRequest(req) { + return { + q: req.query + } + } + }); + + let receivedValue: any; + + router.get({ path: '/' }, async (val, req, res) => { + receivedValue = val; + return res.noContent(); + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app).get('/foo?bar=quux') + + expect(receivedValue).toEqual({ + q: { + bar: 'quux' + } + }); +}); + +test('filtered headers', async () => { + expect.assertions(1); + + const router = new Router('/foo'); + + let filteredHeaders: any; + + router.get({ path: '/' }, async (val, req, res) => { + filteredHeaders = req.getFilteredHeaders(['x-kibana-foo', 'host']); + + return res.noContent(); + }); + + server.registerRouter(router); + + await server.start(port, '127.0.0.1'); + + await supertest(app) + .get('/foo?bar=quux') + .set('x-kibana-foo', 'bar') + .set('x-kibana-bar', 'quux'); + + expect(filteredHeaders).toEqual({ + 'x-kibana-foo': 'bar', + host: `127.0.0.1:${port}` + }) +}); diff --git a/platform/server/http/__tests__/HttpService.test.ts b/platform/server/http/__tests__/HttpService.test.ts new file mode 100644 index 00000000000000..d32f36adf69114 --- /dev/null +++ b/platform/server/http/__tests__/HttpService.test.ts @@ -0,0 +1,138 @@ +const mockHttpServer = jest.fn(); + +jest.mock('../HttpServer', () => ({ + HttpServer: mockHttpServer +})); + +import { noop } from 'lodash'; +import { BehaviorSubject } from 'rxjs'; + +import { HttpService } from '../HttpService'; +import { HttpConfig } from '../HttpConfig'; +import { Router } from '../Router'; +import { logger } from '../../../logger/__mocks__' + +beforeEach(() => { + logger._clear(); + mockHttpServer.mockClear(); +}); + +test('creates an http server', () => { + const config = {} as HttpConfig; + + const config$ = new BehaviorSubject(config); + + new HttpService(config$.asObservable(), logger); + + expect(mockHttpServer.mock.instances.length).toBe(1); +}); + +test('starts http server', () => { + const config = { + port: 1234, + host: 'example.org' + } as HttpConfig; + + const config$ = new BehaviorSubject(config); + + const httpServer = { + isListening: () => false, + start: jest.fn(), + stop: noop + }; + mockHttpServer.mockImplementation(() => httpServer); + + const service = new HttpService(config$.asObservable(), logger); + + service.start(); + + expect(httpServer.start).toHaveBeenCalledTimes(1); + expect(logger._collect()).toMatchSnapshot(); +}); + +test('logs error is already started', () => { + const config = {} as HttpConfig; + + const config$ = new BehaviorSubject(config); + + const httpServer = { + isListening: () => true, + start: noop, + stop: noop + }; + mockHttpServer.mockImplementation(() => httpServer); + + const service = new HttpService(config$.asObservable(), logger); + + service.start(); + + expect(logger._collect()).toMatchSnapshot(); +}); + +test('stops http server', () => { + const config = {} as HttpConfig; + + const config$ = new BehaviorSubject(config); + + const httpServer = { + isListening: () => false, + start: noop, + stop: jest.fn() + }; + mockHttpServer.mockImplementation(() => httpServer); + + const service = new HttpService(config$.asObservable(), logger); + + service.start(); + + expect(httpServer.stop).toHaveBeenCalledTimes(0); + + service.stop(); + + expect(httpServer.stop).toHaveBeenCalledTimes(1); +}); + +test('register route handler', () => { + const config = {} as HttpConfig; + + const config$ = new BehaviorSubject(config); + + const httpServer = { + isListening: () => false, + start: noop, + stop: noop, + registerRouter: jest.fn() + }; + mockHttpServer.mockImplementation(() => httpServer); + + const service = new HttpService(config$.asObservable(), logger); + + const router = new Router('/foo'); + service.registerRouter(router); + + expect(httpServer.registerRouter).toHaveBeenCalledTimes(1); + expect(httpServer.registerRouter).toHaveBeenLastCalledWith(router); + expect(logger._collect()).toMatchSnapshot(); +}); + +test('throws if registering route handler after http server is started', () => { + const config = {} as HttpConfig; + + const config$ = new BehaviorSubject(config); + + const httpServer = { + isListening: () => true, + start: noop, + stop: noop, + registerRouter: jest.fn() + }; + mockHttpServer.mockImplementation(() => httpServer); + + const service = new HttpService(config$.asObservable(), logger); + + const router = new Router('/foo'); + service.registerRouter(router); + + expect(httpServer.registerRouter).toHaveBeenCalledTimes(0); + expect(logger._collect()).toMatchSnapshot(); +}); diff --git a/platform/server/http/__tests__/__snapshots__/HttpConfig.test.ts.snap b/platform/server/http/__tests__/__snapshots__/HttpConfig.test.ts.snap new file mode 100644 index 00000000000000..f61dd654c18349 --- /dev/null +++ b/platform/server/http/__tests__/__snapshots__/HttpConfig.test.ts.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`has defaults for config 1`] = ` +Object { + "basePath": undefined, + "host": "localhost", + "maxPayload": ByteSizeValue { + "valueInBytes": 1048576, + }, + "port": 5601, + "ssl": Object { + "certificate": undefined, + "certificateAuthorities": undefined, + "cipherSuites": Array [ + "ECDHE-RSA-AES128-GCM-SHA256", + "ECDHE-ECDSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-ECDSA-AES256-GCM-SHA384", + "DHE-RSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES128-SHA256", + "DHE-RSA-AES128-SHA256", + "ECDHE-RSA-AES256-SHA384", + "DHE-RSA-AES256-SHA384", + "ECDHE-RSA-AES256-SHA256", + "DHE-RSA-AES256-SHA256", + "HIGH", + "!aNULL", + "!eNULL", + "!EXPORT", + "!DES", + "!RC4", + "!MD5", + "!PSK", + "!SRP", + "!CAMELLIA", + ], + "enabled": false, + "key": undefined, + "keyPassphrase": undefined, + "supportedProtocols": undefined, + }, +} +`; + +exports[`throws if invalid hostname 1`] = `"[host]: must be a valid hostname"`; + +exports[`throws is basepath appends a slash 1`] = `"[basePath]: must start with a slash, don't end with one"`; + +exports[`throws is basepath is missing prepended slash 1`] = `"[basePath]: must start with a slash, don't end with one"`; diff --git a/platform/server/http/__tests__/__snapshots__/HttpService.test.ts.snap b/platform/server/http/__tests__/__snapshots__/HttpService.test.ts.snap new file mode 100644 index 00000000000000..511d96bb0d3d2b --- /dev/null +++ b/platform/server/http/__tests__/__snapshots__/HttpService.test.ts.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`logs error is already started 1`] = ` +Object { + "debug": Array [], + "error": Array [ + Array [ + "Received new HTTP config after server was started. Config will **not** be applied.", + ], + ], + "info": Array [], +} +`; + +exports[`register route handler 1`] = ` +Object { + "debug": Array [], + "error": Array [], + "info": Array [ + Array [ + "registering route handler for [/foo]", + ], + ], +} +`; + +exports[`starts http server 1`] = ` +Object { + "debug": Array [], + "error": Array [], + "info": Array [ + Array [ + "starting http server [example.org:1234]", + ], + ], +} +`; + +exports[`throws if registering route handler after http server is started 1`] = ` +Object { + "debug": Array [], + "error": Array [ + Array [ + "Received new router [/foo] after server was started. Router will **not** be applied.", + ], + ], + "info": Array [], +} +`; diff --git a/platform/server/http/index.ts b/platform/server/http/index.ts new file mode 100644 index 00000000000000..8bddb1d665d6cd --- /dev/null +++ b/platform/server/http/index.ts @@ -0,0 +1,21 @@ +import { Observable } from 'rxjs'; + +import { HttpService } from './HttpService'; +import { HttpConfig } from './HttpConfig'; +import { LoggerFactory } from '../../logger'; + +export { Router, RouterOptions, KibanaRequest } from './Router'; +export { HttpService }; + +export { HttpConfig }; + +export class HttpModule { + readonly service: HttpService; + + constructor( + readonly config$: Observable, + logger: LoggerFactory + ) { + this.service = new HttpService(this.config$, logger); + } +} \ No newline at end of file diff --git a/platform/server/index.ts b/platform/server/index.ts new file mode 100644 index 00000000000000..aef78b91d1a4a2 --- /dev/null +++ b/platform/server/index.ts @@ -0,0 +1,74 @@ +import { ConfigService } from '../config'; +import { HttpModule, HttpConfig } from './http'; +import { ElasticsearchModule, ElasticsearchConfigs } from './elasticsearch'; +import { KibanaModule, KibanaConfig } from './kibana'; +import { Logger, LoggerFactory } from '../logger'; +import { PluginsService } from './plugins/PluginsService'; +import { PluginSystem } from './plugins/PluginSystem'; + +export class Server { + private readonly elasticsearch: ElasticsearchModule; + private readonly http: HttpModule; + private readonly kibana: KibanaModule; + private readonly plugins: PluginsService; + private readonly log: Logger; + + constructor( + private readonly configService: ConfigService, + logger: LoggerFactory + ) { + this.log = logger.get('server'); + + const kibanaConfig$ = configService.atPath('kibana', KibanaConfig); + const httpConfig$ = configService.atPath('server', HttpConfig); + const elasticsearchConfigs$ = configService.atPath( + 'elasticsearch', + ElasticsearchConfigs + ); + + this.elasticsearch = new ElasticsearchModule(elasticsearchConfigs$, logger); + this.kibana = new KibanaModule(kibanaConfig$); + this.http = new HttpModule(httpConfig$, logger); + + const core = { + elasticsearch: this.elasticsearch, + kibana: this.kibana, + http: this.http, + configService, + logger + }; + + this.plugins = new PluginsService( + configService.env.pluginsDir, + new PluginSystem(core, logger), + logger + ); + } + + async start() { + this.log.info('starting server :tada:'); + + this.http.service.registerRouter(this.elasticsearch.createRoutes()); + + await this.elasticsearch.service.start(); + await this.plugins.start(); + await this.http.service.start(); + + const unhandledConfigPaths = await this.configService.getUnusedPaths(); + if (unhandledConfigPaths.length > 0) { + throw new Error( + `some config paths are not handled: ${JSON.stringify( + unhandledConfigPaths + )}` + ); + } + } + + stop() { + this.log.debug('stopping server'); + + this.http.service.stop(); + this.plugins.stop(); + this.elasticsearch.service.stop(); + } +} diff --git a/platform/server/kibana/KibanaConfig.ts b/platform/server/kibana/KibanaConfig.ts new file mode 100644 index 00000000000000..1c1f26f2cb73ce --- /dev/null +++ b/platform/server/kibana/KibanaConfig.ts @@ -0,0 +1,18 @@ +import { Schema, typeOfSchema } from '../../types'; + +const createKibanaSchema = (schema: Schema) => + schema.object({ + index: schema.string({ defaultValue: '.kibana' }) + }); + +const kibanaConfigType = typeOfSchema(createKibanaSchema); + +export class KibanaConfig { + static createSchema = createKibanaSchema; + + readonly index: string; + + constructor(config: typeof kibanaConfigType) { + this.index = config.index; + } +} diff --git a/platform/server/kibana/index.ts b/platform/server/kibana/index.ts new file mode 100644 index 00000000000000..3c6d492cec7285 --- /dev/null +++ b/platform/server/kibana/index.ts @@ -0,0 +1,9 @@ +import { Observable } from 'rxjs'; + +import { KibanaConfig } from './KibanaConfig'; + +export { KibanaConfig }; + +export class KibanaModule { + constructor(readonly config$: Observable) {} +} diff --git a/platform/server/plugins/KibanaPluginValues.ts b/platform/server/plugins/KibanaPluginValues.ts new file mode 100644 index 00000000000000..35d69bef0df6a8 --- /dev/null +++ b/platform/server/plugins/KibanaPluginValues.ts @@ -0,0 +1,59 @@ +import { + KibanaCoreModules, + KibanaPluginFeatures, + ConfigWithSchema +} from '../../types'; +import * as schema from '../../lib/schema'; +import { Router, RouterOptions } from '../http'; + +/** + * This is the full plugin API exposed from Kibana core, everything else is + * exposed through the plugins themselves. + * + * This is called for each plugin when it's created, so each plugin gets its own + * version of these values. + * + * We should aim to be restrictive and specific in the apis that we expose. + * + * @param pluginName The name of the plugin we're building these values for + * @param kibana The core Kibana features + */ +export function createKibanaValuesForPlugin( + pluginName: string, + kibana: KibanaCoreModules +): KibanaPluginFeatures { + return { + logger: { + get: (...namespace) => { + return kibana.logger.get('plugins', pluginName, ...namespace); + } + }, + util: { + schema + }, + elasticsearch: { + service: kibana.elasticsearch.service, + config$: kibana.elasticsearch.config$ + }, + kibana: { + config$: kibana.kibana.config$ + }, + http: { + createAndRegisterRouter: (path: string, options: RouterOptions) => { + const router = new Router(path, options); + kibana.http.service.registerRouter(router); + return router; + } + }, + config: { + atPath: ( + path: string | string[], + ConfigClass: ConfigWithSchema + ) => kibana.configService.atPath(path, ConfigClass), + optionalAtPath: ( + path: string | string[], + ConfigClass: ConfigWithSchema + ) => kibana.configService.optionalAtPath(path, ConfigClass) + } + }; +} diff --git a/platform/server/plugins/Plugin.ts b/platform/server/plugins/Plugin.ts new file mode 100644 index 00000000000000..d7e6e5c5da7eae --- /dev/null +++ b/platform/server/plugins/Plugin.ts @@ -0,0 +1,117 @@ +import { + BasePluginsType, + KibanaFunctionalPlugin, + KibanaPluginStatic +} from './types'; +import { Logger, LoggerFactory } from '../../logger'; +import { createKibanaValuesForPlugin } from './KibanaPluginValues'; +import { KibanaCoreModules } from '../../types'; + +type LifecycleCallback = () => void; + +// `isClass` is forked from https://github.com/miguelmota/is-class/blob/master/is-class.js +// MIT licensed, copyright 2014 Miguel Mota +var fnToString = Function.prototype.toString; + +function fnBody(fn: any) { + return fnToString + .call(fn) + .replace(/^[^{]*{\s*/, '') + .replace(/\s*}[^}]*$/, ''); +} + +function isClass(fn: any) { + return ( + typeof fn === 'function' && + ( + /^class\s/.test(fnToString.call(fn)) || + /^.*classCallCheck\(/.test(fnBody(fn)) // babel.js + ) + ); +} + +function isKibanaFunctionalPlugin< + DependenciesType extends BasePluginsType, + ExposableType +>( + val: + | KibanaFunctionalPlugin + | KibanaPluginStatic +): val is KibanaFunctionalPlugin { + return !isClass(val); +} + +export class Plugin { + private stopCallbacks: LifecycleCallback[] = []; + private exposedValues?: ExposableType; + private log: Logger; + + // TODO If we end up not being super-dynamic about using `readdir` for reading + // plugins in all locations we could consider making some of the types below + // stricter (e.g. instead of typing `name` and `dependencies` as `string` + // below , we can check them against `DependenciesType` and `ExposableType`), + // See `git show 7e41eec17:platform/server/plugins/Plugin.ts` + constructor( + readonly name: string, + readonly dependencies: string[], + private readonly run: + | KibanaFunctionalPlugin + | KibanaPluginStatic, + logger: LoggerFactory + ) { + this.log = logger.get('plugins', name); + } + + getExposedValues(): ExposableType { + if (this.exposedValues === undefined) { + throw new Error( + 'trying to get the exposed value of a plugin that is NOT running' + ); + } + + return this.exposedValues; + } + + start( + kibanaModules: KibanaCoreModules, + dependenciesValues: DependenciesType + ) { + const kibanaValues = createKibanaValuesForPlugin( + this.name, + kibanaModules + ); + + this.log.info('starting plugin'); + + let value: ExposableType; + if (isKibanaFunctionalPlugin(this.run)) { + value = this.run.call(null, kibanaValues, dependenciesValues); + } else { + const r = new this.run(kibanaValues, dependenciesValues); + value = r.start(); + + this.onStop(() => { + r.stop && r.stop(); + }); + } + + // TODO throw if then-able? To make sure no one async/awaits while processing the plugin? + // if (isPromise(value)) { + // throw new Error('plugin cannot return a promise') + // } + + this.exposedValues = typeof value === 'undefined' + ? {} as ExposableType + : value; + } + + private onStop(cb: LifecycleCallback) { + this.stopCallbacks.push(cb); + } + + stop() { + this.log.info('stopping plugin'); + this.stopCallbacks.forEach(cb => cb()); + this.exposedValues = undefined; + } +} diff --git a/platform/server/plugins/PluginSystem.ts b/platform/server/plugins/PluginSystem.ts new file mode 100644 index 00000000000000..1df5230fcdc8cf --- /dev/null +++ b/platform/server/plugins/PluginSystem.ts @@ -0,0 +1,84 @@ +import { Plugin } from './Plugin'; +import { PluginName, BasePluginsType } from './types'; +import { KibanaCoreModules } from '../../types'; +import { Logger, LoggerFactory } from '../../logger'; +import { topologicalSort } from '../../lib/topologicalSort'; + +// We need this helper for the types to be correct +// (otherwise it assumes an array of A|B instead of a tuple [A,B]) +const toTuple = (a: A, b: B): [A, B] => [a, b]; + +function toSortable(plugins: Map>) { + const dependenciesByPlugin = [...plugins.entries()] + .map(([name, plugin]) => toTuple(name, plugin.dependencies || [])); + return new Map(dependenciesByPlugin); +} + +function getSortedPluginNames(plugins: Map>) { + const sorted = topologicalSort(toSortable(plugins)); + return [...sorted]; +} + +export class PluginSystem { + private readonly plugins = new Map>(); + private readonly log: Logger; + + constructor( + private readonly kibanaModules: KibanaCoreModules, + logger: LoggerFactory + ) { + this.log = logger.get('pluginsystem'); + } + + addPlugin< + DependenciesType extends BasePluginsType, + ExposableType = void + >(plugin: Plugin) { + if (this.plugins.has(plugin.name)) { + throw new Error(`a plugin named [${plugin.name}] has already been added`); + } + + this.log.debug(`adding plugin [${plugin.name}]`); + this.plugins.set(plugin.name, plugin); + } + + startPlugins() { + const sortedPlugins = getSortedPluginNames(this.plugins); + + this.log.info( + `starting [${this.plugins.size}] plugins: [${sortedPlugins}]` + ); + + sortedPlugins + .map(pluginName => this.plugins.get(pluginName)!) + .forEach(plugin => { + this.startPlugin(plugin); + }); + } + + private startPlugin< + DependenciesType extends BasePluginsType, + ExposableType = void + >(plugin: Plugin) { + const dependenciesValues = {} as DependenciesType; + + for (const dependency of plugin.dependencies) { + dependenciesValues[dependency] = this.plugins.get(dependency)!.getExposedValues(); + } + + plugin.start(this.kibanaModules, dependenciesValues); + } + + /** + * Stop all plugins in the reverse order of when they were started + */ + stopPlugins() { + getSortedPluginNames(this.plugins) + .map(pluginName => this.plugins.get(pluginName)!) + .reverse() + .forEach(plugin => { + plugin.stop(); + this.plugins.delete(plugin.name); + }); + } +} diff --git a/platform/server/plugins/PluginsService.ts b/platform/server/plugins/PluginsService.ts new file mode 100644 index 00000000000000..04723baad9b01e --- /dev/null +++ b/platform/server/plugins/PluginsService.ts @@ -0,0 +1,60 @@ +import { readdir } from 'fs'; +import { Observable } from 'rxjs'; + +import { Plugin } from './Plugin'; +import { PluginSystem } from './PluginSystem'; +import { Logger, LoggerFactory } from '../../logger'; + +const readDirAsObservable = Observable.bindNodeCallback(readdir); + +export class PluginsService { + private readonly log: Logger; + + constructor( + private readonly pluginsDir: string, + private readonly pluginSystem: PluginSystem, + private readonly logger: LoggerFactory + ) { + this.log = this.logger.get('plugins'); + } + + start() { + this.readPlugins().subscribe({ + next: plugin => { + this.pluginSystem.addPlugin(plugin); + }, + complete: () => { + this.pluginSystem.startPlugins(); + } + }); + } + + stop() { + this.pluginSystem.stopPlugins(); + } + + /** + * Read all plugin configs from disk and returns a topologically sorted list + * of plugins. + */ + private readPlugins() { + return readDirAsObservable(this.pluginsDir) + .mergeMap(dirs => dirs) + .map(name => { + const pluginPath = `${this.pluginsDir}/${name}/`; + const json = require(pluginPath); + + if (!('plugin' in json)) { + throw new Error( + `'plugin' definition missing in plugin [${pluginPath}]` + ); + } + + // TODO validate these values + const plugin = json.plugin; + const dependencies = json.dependencies || []; + + return new Plugin(name, dependencies, plugin, this.logger); + }); + } +} diff --git a/platform/server/plugins/README.md b/platform/server/plugins/README.md new file mode 100644 index 00000000000000..a795d258c454b0 --- /dev/null +++ b/platform/server/plugins/README.md @@ -0,0 +1,42 @@ +# The Kibana Plugin System + +## How plugins are defined + +## Function or class + +A Kibana plugin can either be defined as a function or a class. + +As a function: + +```js +TODO example +``` + +As a class: + +```js +TODO +``` + +## Dependencies + +## Lifecycle + +Plugins are started when Kibana starts up. There is no way to start plugins +after Kibana is started. + +Any plugin that is implemented as a class _MUST_ implement the `start` method, +which will be called when the plugin is started. The `start` method returns the +public api of the plugin. + +Plugins are stopped when Kibana is stopped. There is no way to stop a plugin +_before_ Kibana is stopped. Any plugin that is implemented as a `class` can +specify a `stop` method that gets called when the plugin is stopped. + +```js +TODO example of "stop" +``` + +## Exposing values + +## Typed vs untyped plugins diff --git a/platform/server/plugins/__tests__/PluginSystem.test.ts b/platform/server/plugins/__tests__/PluginSystem.test.ts new file mode 100644 index 00000000000000..590eac2a1f024f --- /dev/null +++ b/platform/server/plugins/__tests__/PluginSystem.test.ts @@ -0,0 +1,343 @@ +jest.mock('../KibanaPluginValues', () => { + return { + createKibanaValuesForPlugin: () => ({}) + } +}) + +import { Plugin } from '../Plugin'; +import { PluginSystem } from '../PluginSystem'; +import { KibanaCoreModules } from '../../../types'; +import { logger } from '../../../logger/__mocks__' + +// To make typings work in the tests +const coreValues = {} as KibanaCoreModules; + +test('can register value', () => { + expect.assertions(1); + + type Foo = { + foo: { + value: string + } + } + + const foo = new Plugin<{}, Foo['foo']>('foo', [], config => { + return { + value: 'my-value' + } + }, logger); + + const bar = new Plugin('bar', ['foo'], (kibana, deps) => { + expect(deps.foo).toEqual({ value: 'my-value' }); + }, logger); + + const plugins = new PluginSystem(coreValues, logger); + plugins.addPlugin(foo); + plugins.addPlugin(bar); + plugins.startPlugins(); +}); + +test('can register function', () => { + expect.assertions(2); + + type Foo = { + foo: { + fn: (val: string) => string + } + } + + const foo = new Plugin<{}, Foo['foo']>('foo', [], config => { + return { + fn: val => `test-${val}` + } + }, logger); + + const bar = new Plugin('bar', ['foo'], (kibana, deps) => { + expect(deps.foo).toBeDefined(); + expect(deps.foo.fn('some-value')).toBe('test-some-value'); + }, logger); + + const plugins = new PluginSystem(coreValues, logger); + plugins.addPlugin(foo); + plugins.addPlugin(bar); + plugins.startPlugins(); +}); + +test('can register value with same name across plugins', () => { + expect.assertions(2); + + type Foo = { + foo: { + value: string + } + } + + type Bar = { + bar: { + value: string + } + } + + + const foo = new Plugin<{}, Foo['foo']>('foo', [], kibana => { + return { + value: 'value-foo' + } + }, logger); + + const bar = new Plugin<{}, Bar['bar']>('bar', [], kibana => { + return { + value: 'value-bar' + } + }, logger); + + const quux = new Plugin('quux', ['foo', 'bar'], (kibana, deps) => { + expect(deps.foo).toEqual({ value: 'value-foo' }); + expect(deps.bar).toEqual({ value: 'value-bar' }); + }, logger); + + const plugins = new PluginSystem(coreValues, logger); + plugins.addPlugin(foo); + plugins.addPlugin(bar); + plugins.addPlugin(quux); + plugins.startPlugins(); +}); + +test('receives values from dependencies but not transitive dependencies', () => { + expect.assertions(3); + + type Grandchild = { + grandchild: { + value: string + } + } + + const grandchild = new Plugin<{}, Grandchild['grandchild']>('grandchild', [], kibana => { + return { + value: 'grandchild' + } + }, logger); + + type Child = { + child: { + value: string + } + } + + const child = new Plugin('child', ['grandchild'], (kibana, deps) => { + expect(deps.grandchild).toEqual({ value: 'grandchild' }); + + return { + value: 'child' + } + }, logger); + + const parent = new Plugin('parent', ['child'], (kibana, deps) => { + expect(deps.child).toEqual({ value: 'child' }); + expect(deps.grandchild).toBeUndefined(); + }, logger); + + const plugins = new PluginSystem(coreValues, logger); + plugins.addPlugin(grandchild); + plugins.addPlugin(child); + plugins.addPlugin(parent); + plugins.startPlugins(); +}); + +test('keeps ref on registered value', () => { + expect.assertions(1); + + type Child = { + child: { + value: {} + } + } + + const myRef = {}; + + const child = new Plugin<{}, Child['child']>('child', [], kibana => { + return { + value: myRef + } + }, logger); + + const parent = new Plugin('parent', ['child'], (kibana, deps) => { + expect(deps.child.value).toBe(myRef); + }, logger); + + const plugins = new PluginSystem(coreValues, logger); + plugins.addPlugin(child); + plugins.addPlugin(parent); + plugins.startPlugins(); +}); + +test('can register multiple values in single plugin', () => { + expect.assertions(1); + + type Child = { + child: { + value1: number, + value2: number + } + } + + const child = new Plugin<{}, Child['child']>('child', [], kibana => { + return { + value1: 1, + value2: 2 + } + }, logger); + + const parent = new Plugin('parent', ['child'], (kibana, deps) => { + expect(deps.child).toEqual({ + value1: 1, + value2: 2 + }); + }, logger); + + const plugins = new PluginSystem(coreValues, logger); + plugins.addPlugin(child); + plugins.addPlugin(parent); + plugins.startPlugins(); +}); + +test('plugins can be typed', () => { + expect.assertions(5); + + type Foo = { + foo: { + value1: number, + value2: number + }; + } + + const foo = new Plugin<{}, Foo['foo']>('foo', [], kibana => { + return { + value1: 1, + value2: 2 + } + }, logger); + + type Bar = { + bar: { + value1: string, + value2: boolean + } + } + + const bar = new Plugin('bar', ['foo'], (kibana, deps) => { + expect(deps.foo.value1).toBe(1); + + return { + value1: 'test', + value2: false + } + }, logger); + + const quux = new Plugin('quux', ['foo', 'bar'], (kibana, deps) => { + expect(deps.foo.value1).toBe(1); + expect(deps.foo.value2).toBe(2); + + expect(deps.bar.value1).toBe('test'); + expect(deps.bar.value2).toBe(false); + }, logger); + + const plugins = new PluginSystem(coreValues, logger); + plugins.addPlugin(foo); + plugins.addPlugin(bar); + plugins.addPlugin(quux); + plugins.startPlugins(); +}); + +test('ensure `this` is not specified when starting a plugin', () => { + expect.assertions(1); + + const foo = new Plugin<{}, void>('foo', [], function(this: any, config) { + expect(this).toBeNull(); + }, logger); + + const plugins = new PluginSystem(coreValues, logger); + plugins.addPlugin(foo); + plugins.startPlugins(); +}); + + +test("throws if starting a plugin that depends on a plugin that's not yet started", () => { + const foo = new Plugin<{}, void>('foo', ['does-not-exist'], () => {}, logger); + + const plugins = new PluginSystem(coreValues, logger); + + plugins.addPlugin(foo); + + expect(() => { + plugins.startPlugins(); + }).toThrowErrorMatchingSnapshot(); +}); + +test("throws if adding a plugin that's already added", () => { + const foo = new Plugin<{}, void>('foo', [], () => {}, logger); + + const plugins = new PluginSystem(coreValues, logger); + + plugins.addPlugin(foo); + expect(() => { + plugins.addPlugin(foo); + }).toThrowErrorMatchingSnapshot(); +}); + +test('stops plugins in reverse order of started order', () => { + const events: string[] = []; + + class FooPlugin { + start() { + events.push('start foo'); + } + stop() { + events.push('stop foo'); + } + } + + class BarPlugin { + start() { + events.push('start bar') + } + stop() { + events.push('stop bar'); + } + } + + const foo = new Plugin<{}, void>('foo', [], FooPlugin, logger); + const bar = new Plugin<{}, void>('bar', [], BarPlugin, logger); + + const plugins = new PluginSystem(coreValues, logger); + + plugins.addPlugin(foo); + plugins.addPlugin(bar); + + plugins.startPlugins(); + plugins.stopPlugins(); + + expect(events).toEqual(['start bar', 'start foo', 'stop foo', 'stop bar']); +}); + +test('can add plugins before adding its dependencies', () => { + expect.assertions(1); + + type Foo = { + foo: string; + } + + const foo = new Plugin<{}, Foo['foo']>('foo', [], kibana => { + return 'value'; + }, logger); + + const bar = new Plugin('bar', ['foo'], (kibana, deps) => { + expect(deps.foo).toBe('value'); + }, logger); + + const plugins = new PluginSystem(coreValues, logger); + // `bar` depends on `foo`, but we add it first + plugins.addPlugin(bar); + plugins.addPlugin(foo); + plugins.startPlugins(); +}); diff --git a/platform/server/plugins/__tests__/PluginsService.test.ts b/platform/server/plugins/__tests__/PluginsService.test.ts new file mode 100644 index 00000000000000..41738f1496bd26 --- /dev/null +++ b/platform/server/plugins/__tests__/PluginsService.test.ts @@ -0,0 +1,49 @@ +// TODO For some weird reason the tests fail to read correctly from the +// filesystem unless this is here. +const mockFs: any = jest.genMockFromModule('fs'); +mockFs.readdir = (err: any, cb: any) => cb(null, ['foo', 'bar']); +jest.mock('fs', () => mockFs); + +import { pick } from 'lodash'; +import { resolve } from 'path'; + +import { PluginsService } from '../PluginsService'; +import { logger } from '../../../logger/__mocks__'; + +const examplesPluginsDir = resolve(__dirname, './examplePlugins'); + +let mockPluginSystem: any = {}; + +beforeEach(() => { + mockPluginSystem = { + addPlugin: jest.fn(), + startPlugins: jest.fn(), + stopPlugins: jest.fn() + }; +}); + +test('starts plugins', () => { + const pluginsService = new PluginsService(examplesPluginsDir, mockPluginSystem, logger); + + pluginsService.start(); + + expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); + expect(mockPluginSystem.startPlugins).toHaveBeenCalledTimes(1); + + const pluginsAdded = mockPluginSystem.addPlugin.mock.calls; + + const foo = pick(pluginsAdded[0][0], ['name', 'dependencies']); + expect(foo).toMatchSnapshot(); + + const bar = pick(pluginsAdded[1][0], ['name', 'dependencies']); + expect(bar).toMatchSnapshot(); +}); + +test('stops plugins', () => { + const pluginsService = new PluginsService(examplesPluginsDir, mockPluginSystem, logger); + + pluginsService.start(); + pluginsService.stop(); + + expect(mockPluginSystem.stopPlugins).toHaveBeenCalledTimes(1); +}); diff --git a/platform/server/plugins/__tests__/__snapshots__/PluginSystem.test.ts.snap b/platform/server/plugins/__tests__/__snapshots__/PluginSystem.test.ts.snap new file mode 100644 index 00000000000000..82d4defc35b098 --- /dev/null +++ b/platform/server/plugins/__tests__/__snapshots__/PluginSystem.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws if adding a plugin that's already added 1`] = `"a plugin named [foo] has already been added"`; + +exports[`throws if starting a plugin that depends on a plugin that's not yet started 1`] = `"Topological ordering did not complete, these edges could not be ordered: [[\\"foo\\",[\\"does-not-exist\\"]]]"`; diff --git a/platform/server/plugins/__tests__/__snapshots__/PluginsService.test.ts.snap b/platform/server/plugins/__tests__/__snapshots__/PluginsService.test.ts.snap new file mode 100644 index 00000000000000..63bafd617211a7 --- /dev/null +++ b/platform/server/plugins/__tests__/__snapshots__/PluginsService.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`starts plugins 1`] = ` +Object { + "dependencies": Array [], + "name": "foo", +} +`; + +exports[`starts plugins 2`] = ` +Object { + "dependencies": Array [ + "foo", + ], + "name": "bar", +} +`; diff --git a/platform/server/plugins/__tests__/examplePlugins/bar/index.ts b/platform/server/plugins/__tests__/examplePlugins/bar/index.ts new file mode 100644 index 00000000000000..d962d1c398a135 --- /dev/null +++ b/platform/server/plugins/__tests__/examplePlugins/bar/index.ts @@ -0,0 +1,20 @@ +import { KibanaFunctionalPlugin } from '../../../../../server/plugins/types'; +import { FooPluginType } from '../foo'; + +export const dependencies = ['foo']; + +interface BarExports { + fromFoo: FooPluginType['foo']['value']; + value: string; +} + +export interface BarPluginType { + bar: BarExports; +} + +export const plugin: KibanaFunctionalPlugin = (kibana, deps) => { + return { + fromFoo: deps.foo.value, + value: 'bar' + } +} diff --git a/platform/server/plugins/__tests__/examplePlugins/foo/index.ts b/platform/server/plugins/__tests__/examplePlugins/foo/index.ts new file mode 100644 index 00000000000000..eb667ec427e3ac --- /dev/null +++ b/platform/server/plugins/__tests__/examplePlugins/foo/index.ts @@ -0,0 +1,17 @@ +import { KibanaFunctionalPlugin } from '../../../../../server/plugins/types'; + +export const dependencies = []; + +interface FooExports { + value: string +} + +export interface FooPluginType { + foo: FooExports +} + +export const plugin: KibanaFunctionalPlugin<{}, FooExports> = kibana => { + return { + value: 'foo' + } +} diff --git a/platform/server/plugins/types.ts b/platform/server/plugins/types.ts new file mode 100644 index 00000000000000..c23c08f958a915 --- /dev/null +++ b/platform/server/plugins/types.ts @@ -0,0 +1,44 @@ +import { KibanaPluginFeatures } from '../../types'; + +export type PluginName = string; + +export interface BasePluginsType { + [key: string]: any; +} + +export type KibanaFunctionalPlugin< + DependenciesType extends BasePluginsType, + ExposableType = void +> = ( + kibana: KibanaPluginFeatures, + plugins: DependenciesType +) => ExposableType; + +// TODO We can't type the constructor, so we have no way of typing +// the `DependenciesType` in the same way as we do for `KibanaFunctionalPlugin`. +// UNLESS we create a class you can `import`, of course. +// +// From the TypeScript core team: +// > More formally, a class implementing an interface is a contract on what an +// > instance of the class has. Since an instance of a class won't contain a +// > construct signature, it cannot satisfy the interface. +// +// If we move these deps to `start` instead of the constructor there's another +// relevant issue regarding contextual typing, which means that all types must +// be explicitely listed in `start` in every plugin. See +// https://github.com/Microsoft/TypeScript/pull/10610 for a "dead-ish" WIP. +export interface KibanaPluginStatic< + DependenciesType extends BasePluginsType, + ExposableType = void +> { + new ( + kibana: KibanaPluginFeatures, + plugins: DependenciesType + ): KibanaPlugin +} + +export interface KibanaPlugin { + start(): ExposableType; + + stop?(): void; +} diff --git a/platform/types.ts b/platform/types.ts new file mode 100644 index 00000000000000..363d8b7bbfaab0 --- /dev/null +++ b/platform/types.ts @@ -0,0 +1,75 @@ +import { Observable } from 'rxjs'; + +// TODO inline all of these +import * as schema from './lib/schema'; +import { ConfigService, Env } from './config'; +import { Router, RouterOptions, HttpModule } from './server/http'; +import { KibanaConfig, KibanaModule } from './server/kibana'; +import { + ElasticsearchService, + ElasticsearchConfigs, + ElasticsearchModule +} from './server/elasticsearch'; +import { LoggerFactory } from './logger'; + +export type ElasticsearchClusterType = 'data' | 'admin'; + +export type Schema = typeof schema; + +// TODO +// This _can't_ be part of the types, as it has to be available at runtime. +// It was the only way I was able to grab the return type of `createSchema` in +// the configs in a good way for the constructor. Relevant TS issues to solve +// this at the type level: +// https://github.com/Microsoft/TypeScript/issues/6606 +// https://github.com/Microsoft/TypeScript/issues/14400 +export function typeOfSchema( + fn: (...rest: any[]) => RT +): schema.TypeOf { + return undefined; +} + +export interface KibanaCoreModules { + elasticsearch: ElasticsearchModule; + kibana: KibanaModule; + http: HttpModule; + configService: ConfigService; + logger: LoggerFactory; +} + +export interface KibanaPluginFeatures { + logger: LoggerFactory; + util: { + schema: Schema; + }; + elasticsearch: { + service: ElasticsearchService; + config$: Observable; + }; + kibana: { + config$: Observable; + }; + http: { + createAndRegisterRouter: ( + path: string, + options: RouterOptions + ) => Router; + }; + config: { + atPath: ( + path: string | string[], + ConfigClass: ConfigWithSchema + ) => Observable; + optionalAtPath: ( + path: string | string[], + ConfigClass: ConfigWithSchema + ) => Observable; + }; +} + +export interface ConfigWithSchema { + createSchema: (s: typeof schema) => Schema; + + // require that the constructor matches the schema + new (val: schema.TypeOf, env: Env): Config; +} diff --git a/scripts/platform.js b/scripts/platform.js new file mode 100644 index 00000000000000..be603880124d17 --- /dev/null +++ b/scripts/platform.js @@ -0,0 +1,4 @@ +/* eslint-disable */ + +require('../src/optimize/babel/register'); +require('../ts-tmp/cli'); diff --git a/src/jest/config.json b/src/jest/config.json index bd57190a465912..1dc97c225cdb92 100644 --- a/src/jest/config.json +++ b/src/jest/config.json @@ -2,9 +2,12 @@ "rootDir": "../../", "roots": [ "/src/core_plugins/kibana/public/dashboard", - "/ui_framework/" + "/ui_framework/", + "/platform/" ], "collectCoverageFrom": [ + "platform/**/*.ts", + "!platform/plugins/**/*.ts", "ui_framework/services/**/*.js", "!ui_framework/services/index.js", "!ui_framework/services/**/*/index.js", @@ -19,18 +22,27 @@ "coverageReporters": [ "html" ], + "globals": { + "__TS_CONFIG__": { + "target": "es6" + } + }, + "mapCoverage": true, "moduleFileExtensions": [ + "ts", "js", "json" ], "testMatch": [ - "**/*.test.js" + "**/*.test.js", + "**/*.test.ts" ], "testPathIgnorePatterns": [ "[/\\\\]ui_framework[/\\\\](dist|doc_site|jest)[/\\\\]" ], "transform": { - "^.+\\.js$": "/src/jest/babelTransform.js" + "^.+\\.js$": "/src/jest/babelTransform.js", + "^.+\\.ts$": "/node_modules/ts-jest/preprocessor.js" }, "transformIgnorePatterns": [ "[/\\\\]node_modules[/\\\\].+\\.js$" @@ -38,4 +50,4 @@ "snapshotSerializers": [ "/node_modules/enzyme-to-json/serializer" ] -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000000000..44cb0dd940bacb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "lib": ["dom", "es2017"], + "module": "commonjs", + "strict": true, + "moduleResolution": "node", + "noImplicitReturns": false, + "noUnusedLocals": true, + "outDir": "./ts-tmp", + "sourceMap": 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 00000000000000..aaa9ea3f98d276 --- /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 00000000000000..a901627794f0ee --- /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