diff --git a/.eslintignore b/.eslintignore index 4dae1a36513eef..53679787bdfd4a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -15,6 +15,7 @@ bower_components /src/core_plugins/console/public/tests/webpackShims /src/ui/public/utils/decode_geo_hash.js /src/core_plugins/timelion/public/webpackShims/jquery.flot.* +/src/core/lib/kbn_internal_native_observable /packages/*/target /packages/eslint-config-kibana /packages/eslint-plugin-kibana-custom diff --git a/package.json b/package.json index e8ebd18636bf25..48e1e3459f2fa4 100644 --- a/package.json +++ b/package.json @@ -130,8 +130,10 @@ "glob-all": "3.0.1", "good-squeeze": "2.1.0", "h2o2": "5.1.1", + "h2o2-latest": "npm:h2o2@8.1.2", "handlebars": "4.0.5", "hapi": "14.2.0", + "hapi-latest": "npm:hapi@17.5.0", "hjson": "3.1.0", "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^2.2.1", @@ -194,12 +196,14 @@ "script-loader": "0.7.2", "semver": "^5.5.0", "style-loader": "0.19.0", + "symbol-observable": "^1.2.0", "tar": "2.2.0", "tinygradient": "0.3.0", "tinymath": "0.2.1", "topojson-client": "3.0.0", "trunc-html": "1.0.2", "trunc-text": "1.0.2", + "type-detect": "^4.0.8", "uglifyjs-webpack-plugin": "0.4.6", "ui-select": "0.19.6", "url-loader": "0.5.9", @@ -229,21 +233,31 @@ "@types/angular": "^1.6.45", "@types/babel-core": "^6.25.5", "@types/bluebird": "^3.1.1", + "@types/chance": "^1.0.0", "@types/classnames": "^2.2.3", "@types/eslint": "^4.16.2", "@types/execa": "^0.9.0", "@types/getopts": "^2.0.0", "@types/glob": "^5.0.35", + "@types/hapi-latest": "npm:@types/hapi@17.0.12", + "@types/has-ansi": "^3.0.0", "@types/jest": "^22.2.3", + "@types/joi": "^10.4.4", "@types/jquery": "3.3.1", + "@types/js-yaml": "^3.11.1", "@types/listr": "^0.13.0", "@types/lodash": "^3.10.1", "@types/minimatch": "^2.0.29", + "@types/node": "^8.10.20", "@types/prop-types": "^15.5.3", "@types/react": "^16.3.14", "@types/react-dom": "^16.0.5", "@types/redux": "^3.6.31", "@types/redux-actions": "^2.2.1", + "@types/sinon": "^5.0.0", + "@types/strip-ansi": "^3.0.0", + "@types/supertest": "^2.0.4", + "@types/type-detect": "^4.0.1", "angular-mocks": "1.4.7", "babel-eslint": "8.1.2", "babel-jest": "^22.4.3", @@ -283,6 +297,7 @@ "grunt-run": "0.7.0", "gulp-babel": "^7.0.1", "gulp-sourcemaps": "1.7.3", + "has-ansi": "^3.0.0", "husky": "0.8.1", "image-diff": "1.6.0", "istanbul-instrumenter-loader": "3.0.0", diff --git a/src/cli/cluster/base_path_proxy.js b/src/cli/cluster/base_path_proxy.js deleted file mode 100644 index b6cd3f93b22493..00000000000000 --- a/src/cli/cluster/base_path_proxy.js +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Server } from 'hapi'; -import { notFound } from 'boom'; -import { map, sample } from 'lodash'; -import { map as promiseMap, fromNode } from 'bluebird'; -import { Agent as HttpsAgent } from 'https'; -import { readFileSync } from 'fs'; - -import { setupConnection } from '../../server/http/setup_connection'; -import { registerHapiPlugins } from '../../server/http/register_hapi_plugins'; -import { setupLogging } from '../../server/logging'; - -const alphabet = 'abcdefghijklmnopqrztuvwxyz'.split(''); - -export default class BasePathProxy { - constructor(clusterManager, config) { - this.clusterManager = clusterManager; - this.server = new Server(); - - this.targetPort = config.get('dev.basePathProxyTarget'); - this.basePath = config.get('server.basePath'); - - const sslEnabled = config.get('server.ssl.enabled'); - if (sslEnabled) { - this.proxyAgent = new HttpsAgent({ - key: readFileSync(config.get('server.ssl.key')), - passphrase: config.get('server.ssl.keyPassphrase'), - cert: readFileSync(config.get('server.ssl.certificate')), - ca: map(config.get('server.ssl.certificateAuthorities'), readFileSync), - rejectUnauthorized: false - }); - } - - if (!this.basePath) { - this.basePath = `/${sample(alphabet, 3).join('')}`; - config.set('server.basePath', this.basePath); - } - - const ONE_GIGABYTE = 1024 * 1024 * 1024; - config.set('server.maxPayloadBytes', ONE_GIGABYTE); - - setupLogging(this.server, config); - setupConnection(this.server, config); - registerHapiPlugins(this.server, config); - - this.setupRoutes(); - } - - setupRoutes() { - const { clusterManager, server, basePath, targetPort } = this; - - server.route({ - method: 'GET', - path: '/', - handler(req, reply) { - return reply.redirect(basePath); - } - }); - - server.route({ - method: '*', - path: `${basePath}/{kbnPath*}`, - config: { - pre: [ - (req, reply) => { - promiseMap(clusterManager.workers, worker => { - if (worker.type === 'server' && !worker.listening && !worker.crashed) { - return fromNode(cb => { - const done = () => { - worker.removeListener('listening', done); - worker.removeListener('crashed', done); - cb(); - }; - - worker.on('listening', done); - worker.on('crashed', done); - }); - } - }) - .return(undefined) - .nodeify(reply); - } - ], - }, - handler: { - proxy: { - passThrough: true, - xforward: true, - agent: this.proxyAgent, - protocol: server.info.protocol, - host: server.info.host, - port: targetPort, - } - } - }); - - server.route({ - method: '*', - path: `/{oldBasePath}/{kbnPath*}`, - handler(req, reply) { - const { oldBasePath, kbnPath = '' } = req.params; - - const isGet = req.method === 'get'; - const isBasePath = oldBasePath.length === 3; - const isApp = kbnPath.startsWith('app/'); - const isKnownShortPath = ['login', 'logout', 'status'].includes(kbnPath); - - if (isGet && isBasePath && (isApp || isKnownShortPath)) { - return reply.redirect(`${basePath}/${kbnPath}`); - } - - return reply(notFound()); - } - }); - } - - async listen() { - await fromNode(cb => this.server.start(cb)); - this.server.log(['listening', 'info'], `basePath Proxy running at ${this.server.info.uri}${this.basePath}`); - } - -} diff --git a/src/cli/cluster/cluster_manager.js b/src/cli/cluster/cluster_manager.js index 5ae5ca2bfadc64..0543c1030d7140 100644 --- a/src/cli/cluster/cluster_manager.js +++ b/src/cli/cluster/cluster_manager.js @@ -22,9 +22,9 @@ import { debounce, invoke, bindAll, once, uniq } from 'lodash'; import Log from '../log'; import Worker from './worker'; -import BasePathProxy from './base_path_proxy'; import { Config } from '../../server/config/config'; import { transformDeprecations } from '../../server/config/transform_deprecations'; +import { configureBasePathProxy } from './configure_base_path_proxy'; process.env.kbnWorkerType = 'managr'; @@ -33,10 +33,14 @@ export default class ClusterManager { const transformedSettings = transformDeprecations(settings); const config = await Config.withDefaultSchema(transformedSettings); - return new ClusterManager(opts, config); + const basePathProxy = opts.basePath + ? await configureBasePathProxy(config) + : undefined; + + return new ClusterManager(opts, config, basePathProxy); } - constructor(opts, config) { + constructor(opts, config, basePathProxy) { this.log = new Log(opts.quiet, opts.silent); this.addedCount = 0; this.inReplMode = !!opts.repl; @@ -47,17 +51,17 @@ export default class ClusterManager { '--server.autoListen=false', ]; - if (opts.basePath) { - this.basePathProxy = new BasePathProxy(this, config); + if (basePathProxy) { + this.basePathProxy = basePathProxy; optimizerArgv.push( - `--server.basePath=${this.basePathProxy.basePath}`, + `--server.basePath=${this.basePathProxy.getBasePath()}`, '--server.rewriteBasePath=true', ); serverArgv.push( - `--server.port=${this.basePathProxy.targetPort}`, - `--server.basePath=${this.basePathProxy.basePath}`, + `--server.port=${this.basePathProxy.getTargetPort()}`, + `--server.basePath=${this.basePathProxy.getBasePath()}`, '--server.rewriteBasePath=true', ); } @@ -78,6 +82,12 @@ export default class ClusterManager { }) ]; + if (basePathProxy) { + // Pass server worker to the basepath proxy so that it can hold off the + // proxying until server worker is ready. + this.basePathProxy.serverWorker = this.server; + } + // broker messages between workers this.workers.forEach((worker) => { worker.on('broadcast', (msg) => { @@ -120,7 +130,7 @@ export default class ClusterManager { this.setupManualRestart(); invoke(this.workers, 'start'); if (this.basePathProxy) { - this.basePathProxy.listen(); + this.basePathProxy.start(); } } diff --git a/src/cli/cluster/configure_base_path_proxy.js b/src/cli/cluster/configure_base_path_proxy.js new file mode 100644 index 00000000000000..477b10053d1e66 --- /dev/null +++ b/src/cli/cluster/configure_base_path_proxy.js @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Server } from 'hapi'; +import { createBasePathProxy } from '../../core'; +import { setupLogging } from '../../server/logging'; + +export async function configureBasePathProxy(config) { + // New platform forwards all logs to the legacy platform so we need HapiJS server + // here just for logging purposes and nothing else. + const server = new Server(); + setupLogging(server, config); + + const basePathProxy = createBasePathProxy({ server, config }); + + await basePathProxy.configure({ + shouldRedirectFromOldBasePath: path => { + const isApp = path.startsWith('app/'); + const isKnownShortPath = ['login', 'logout', 'status'].includes(path); + + return isApp || isKnownShortPath; + }, + + blockUntil: () => { + // Wait until `serverWorker either crashes or starts to listen. + // The `serverWorker` property should be set by the ClusterManager + // once it creates the worker. + const serverWorker = basePathProxy.serverWorker; + if (serverWorker.listening || serverWorker.crashed) { + return Promise.resolve(); + } + + return new Promise(resolve => { + const done = () => { + serverWorker.removeListener('listening', done); + serverWorker.removeListener('crashed', done); + + resolve(); + }; + + serverWorker.on('listening', done); + serverWorker.on('crashed', done); + }); + }, + }); + + return basePathProxy; +} diff --git a/src/cli/cluster/configure_base_path_proxy.test.js b/src/cli/cluster/configure_base_path_proxy.test.js new file mode 100644 index 00000000000000..01cbaf0bcc9008 --- /dev/null +++ b/src/cli/cluster/configure_base_path_proxy.test.js @@ -0,0 +1,163 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('../../core', () => ({ + createBasePathProxy: jest.fn(), +})); + +jest.mock('../../server/logging', () => ({ + setupLogging: jest.fn(), +})); + +import { Server } from 'hapi'; +import { createBasePathProxy as createBasePathProxyMock } from '../../core'; +import { setupLogging as setupLoggingMock } from '../../server/logging'; +import { configureBasePathProxy } from './configure_base_path_proxy'; + +describe('configureBasePathProxy()', () => { + it('returns `BasePathProxy` instance.', async () => { + const basePathProxyMock = { configure: jest.fn() }; + createBasePathProxyMock.mockReturnValue(basePathProxyMock); + + const basePathProxy = await configureBasePathProxy({}); + + expect(basePathProxy).toBe(basePathProxyMock); + }); + + it('correctly configures `BasePathProxy`.', async () => { + const configMock = {}; + const basePathProxyMock = { configure: jest.fn() }; + createBasePathProxyMock.mockReturnValue(basePathProxyMock); + + await configureBasePathProxy(configMock); + + // Check that logging is configured with the right parameters. + expect(setupLoggingMock).toHaveBeenCalledWith( + expect.any(Server), + configMock + ); + + const [[server]] = setupLoggingMock.mock.calls; + expect(createBasePathProxyMock).toHaveBeenCalledWith({ + config: configMock, + server, + }); + + expect(basePathProxyMock.configure).toHaveBeenCalledWith({ + shouldRedirectFromOldBasePath: expect.any(Function), + blockUntil: expect.any(Function), + }); + }); + + describe('configured with the correct `shouldRedirectFromOldBasePath` and `blockUntil` functions.', async () => { + let serverWorkerMock; + let shouldRedirectFromOldBasePath; + let blockUntil; + beforeEach(async () => { + serverWorkerMock = { + listening: false, + crashed: false, + on: jest.fn(), + removeListener: jest.fn(), + }; + + const basePathProxyMock = { + configure: jest.fn(), + serverWorker: serverWorkerMock, + }; + + createBasePathProxyMock.mockReturnValue(basePathProxyMock); + + await configureBasePathProxy({}); + + [[{ blockUntil, shouldRedirectFromOldBasePath }]] = basePathProxyMock.configure.mock.calls; + }); + + it('`shouldRedirectFromOldBasePath()` returns `false` for unknown paths.', async () => { + expect(shouldRedirectFromOldBasePath('')).toBe(false); + expect(shouldRedirectFromOldBasePath('some-path/')).toBe(false); + expect(shouldRedirectFromOldBasePath('some-other-path')).toBe(false); + }); + + it('`shouldRedirectFromOldBasePath()` returns `true` for `app` and other known paths.', async () => { + expect(shouldRedirectFromOldBasePath('app/')).toBe(true); + expect(shouldRedirectFromOldBasePath('login')).toBe(true); + expect(shouldRedirectFromOldBasePath('logout')).toBe(true); + expect(shouldRedirectFromOldBasePath('status')).toBe(true); + }); + + it('`blockUntil()` resolves immediately if worker has already crashed.', async () => { + serverWorkerMock.crashed = true; + + await expect(blockUntil()).resolves.not.toBeDefined(); + expect(serverWorkerMock.on).not.toHaveBeenCalled(); + expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); + }); + + it('`blockUntil()` resolves immediately if worker is already listening.', async () => { + serverWorkerMock.listening = true; + + await expect(blockUntil()).resolves.not.toBeDefined(); + expect(serverWorkerMock.on).not.toHaveBeenCalled(); + expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); + }); + + it('`blockUntil()` resolves when worker crashes.', async () => { + const blockUntilPromise = blockUntil(); + + expect(serverWorkerMock.on).toHaveBeenCalledTimes(2); + expect(serverWorkerMock.on).toHaveBeenCalledWith( + 'crashed', + expect.any(Function) + ); + + const [, [eventName, onCrashed]] = serverWorkerMock.on.mock.calls; + // Check event name to make sure we call the right callback, + // in Jest 23 we could use `toHaveBeenNthCalledWith` instead. + expect(eventName).toBe('crashed'); + expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); + + onCrashed(); + await expect(blockUntilPromise).resolves.not.toBeDefined(); + + expect(serverWorkerMock.removeListener).toHaveBeenCalledTimes(2); + }); + + it('`blockUntil()` resolves when worker starts listening.', async () => { + const blockUntilPromise = blockUntil(); + + expect(serverWorkerMock.on).toHaveBeenCalledTimes(2); + expect(serverWorkerMock.on).toHaveBeenCalledWith( + 'listening', + expect.any(Function) + ); + + const [[eventName, onListening]] = serverWorkerMock.on.mock.calls; + // Check event name to make sure we call the right callback, + // in Jest 23 we could use `toHaveBeenNthCalledWith` instead. + expect(eventName).toBe('listening'); + expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); + + onListening(); + await expect(blockUntilPromise).resolves.not.toBeDefined(); + + expect(serverWorkerMock.removeListener).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/cli/serve/integration_tests/__snapshots__/reload_logging_config.test.js.snap b/src/cli/serve/integration_tests/__snapshots__/reload_logging_config.test.js.snap index 19fc8b2ef9c7e3..04014e02fbb051 100644 --- a/src/cli/serve/integration_tests/__snapshots__/reload_logging_config.test.js.snap +++ b/src/cli/serve/integration_tests/__snapshots__/reload_logging_config.test.js.snap @@ -13,6 +13,37 @@ Object { ], "type": "log", }, + Object { + "@timestamp": "## @timestamp ##", + "message": "starting server :tada:", + "pid": "## PID ##", + "tags": Array [ + "info", + "server", + ], + "type": "log", + }, + Object { + "@timestamp": "## @timestamp ##", + "message": "registering route handler for [/core]", + "pid": "## PID ##", + "tags": Array [ + "info", + "http", + ], + "type": "log", + }, + Object { + "@timestamp": "## @timestamp ##", + "message": "starting http server [localhost:8274]", + "pid": "## PID ##", + "tags": Array [ + "info", + "http", + "server", + ], + "type": "log", + }, Object { "@timestamp": "## @timestamp ##", "message": "Server running at http://localhost:8274", diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index bb02690d8cbda9..ecb0ae3a53dc55 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -24,8 +24,10 @@ import { resolve } from 'path'; import { fromRoot } from '../../utils'; import { getConfig } from '../../server/path'; +import { Config } from '../../server/config/config'; import { readYamlConfig } from './read_yaml_config'; import { readKeystore } from './read_keystore'; +import { transformDeprecations } from '../../server/config/transform_deprecations'; import { DEV_SSL_CERT_PATH, DEV_SSL_KEY_PATH } from '../dev_ssl'; @@ -245,10 +247,17 @@ export default function (program) { } process.on('SIGHUP', async function reloadConfig() { - const settings = getCurrentSettings(); + const settings = transformDeprecations(getCurrentSettings()); + const config = new Config(kbnServer.config.getSchema(), settings); + kbnServer.server.log(['info', 'config'], 'Reloading logging configuration due to SIGHUP.'); - await kbnServer.applyLoggingConfiguration(settings); + await kbnServer.applyLoggingConfiguration(config); kbnServer.server.log(['info', 'config'], 'Reloaded logging configuration due to SIGHUP.'); + + // If new platform config subscription is active, let's notify it with the updated config. + if (kbnServer.newPlatform) { + kbnServer.newPlatform.updateConfig(config); + } }); return kbnServer; diff --git a/src/core/README.md b/src/core/README.md new file mode 100644 index 00000000000000..e1b7e900147882 --- /dev/null +++ b/src/core/README.md @@ -0,0 +1,18 @@ +# New Platform (Core) + +All Kibana requests will hit the new platform first and it will decide whether request can be +solely handled by the new platform or request should be forwarded to the legacy platform. In this mode new platform does +not read config file directly, but rather transforms config provided by the legacy platform. In addition to that all log +records are forwarded to the legacy platform so that it can layout and output them properly. + +## Starting plugins in the new platform + +Plugins in `../core_plugins` will be started automatically. In addition, dirs to +scan for plugins can be specified in the Kibana config by setting the +`__newPlatform.plugins.scanDirs` value, e.g. + +```yaml +__newPlatform: + plugins: + scanDirs: ['./example_plugins'] +``` diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 00000000000000..326d08e0ec43f0 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { injectIntoKbnServer, createBasePathProxy } from './server/legacy_compat'; diff --git a/src/core/lib/kbn_internal_native_observable/README.md b/src/core/lib/kbn_internal_native_observable/README.md new file mode 100644 index 00000000000000..05e15f9d95bd75 --- /dev/null +++ b/src/core/lib/kbn_internal_native_observable/README.md @@ -0,0 +1,44 @@ +# kbn-internal-native-observable + +This package contains a [spec-compliant][spec] observable implementation that +does _not_ implement any additional helper methods on the observable. + +NB! It is not intended to be used directly. It is exposed through +`../kbn-observable`, which also exposes several helpers, similar to a subset of +features in RxJS. + +## Background + +We only want to expose native JavaScript observables in the api, i.e. exposed +observables should _only_ implement the specific methods defined in the spec. +The primary reason for doing this is that we don't want to couple our plugin +api to a specific version of RxJS (or any other observable library that +implements additional methods on top of the spec). + +As there exists no other library we can use in the interim while waiting for the +Observable spec to reach stage 3, all exposed observables in the Kibana platform +should rely on this package. + +## Why a separate package? + +This package is implemented as a separate package instead of directly in the +platform code base for a couple of reasons. We wanted to copy the +implementation from the [observable proposal][spec] directly (so it's easier to +stay up-to-date with the future spec), and we therefore didn't want to start +adding TS types directly to that implementation. + +We tried to avoid this by implementing the type declaration file separately and +make that part of the build. However, to handle the JS file we would have to +enable the `allowJs` TypeScript compiler option, which doesn't yet play nicely +with the automatic building of declaration files we do in the `kbn-types` +package. + +The best solution we found in the end was to extract this as a separate package +and specify the `types` field in the `package.json`. Then everything works out +of the box. + +There is no other reasons for this to be a separate package, so if we find a +solution to the above we should consider inlining this implementation into the +platform. + +[spec]: https://github.com/tc39/proposal-observable diff --git a/src/core/lib/kbn_internal_native_observable/index.d.ts b/src/core/lib/kbn_internal_native_observable/index.d.ts new file mode 100644 index 00000000000000..9d618e14b249e9 --- /dev/null +++ b/src/core/lib/kbn_internal_native_observable/index.d.ts @@ -0,0 +1,120 @@ +// This adds a symbol type for `Symbol.observable`, which doesn't exist globally +// in TypeScript yet. +declare global { + export interface SymbolConstructor { + readonly observable: symbol; + } +} + +// These types are based on the Observable proposal readme, see +// https://github.com/tc39/proposal-observable#api, with the addition of using +// generics to define the type of the `value`. + +declare namespace Observable { + interface Subscription { + // Cancels the subscription + unsubscribe(): void; + + // A boolean value indicating whether the subscription is closed + closed: boolean; + } + + interface Subscribable { + subscribe( + observerOrNext?: SubscriptionObserver | ((value: T) => void), + error?: (error: any) => void, + complete?: () => void + ): Subscription; + } + + type ObservableInput = Subscribable | Iterable; + + interface SubscriptionObserver { + // Sends the next value in the sequence + next(value: T): void; + + // Sends the sequence error + error(errorValue: Error): void; + + // Sends the completion notification + complete(): void; + + // A boolean value indicating whether the subscription is closed + closed: boolean; + } + + export interface StartObserver { + start(subscription: Subscription): void; + next?(value: T): void; + error?(err: any): void; + complete?(): void; + } + + export interface NextObserver { + start?(subscription: Subscription): void; + next(value: T): void; + error?(err: any): void; + complete?(): void; + } + + interface ErrorObserver { + start?(subscription: Subscription): void; + next?(value: T): void; + error(err: any): void; + complete?(): void; + } + + interface CompletionObserver { + start?(subscription: Subscription): void; + next?(value: T): void; + error?(err: any): void; + complete(): void; + } + + type PartialObserver = + | StartObserver + | NextObserver + | ErrorObserver + | CompletionObserver; + + interface Observer { + // Receives the subscription object when `subscribe` is called + start(subscription: Subscription): void; + + // Receives the next value in the sequence + next(value: T): void; + + // Receives the sequence error + error(errorValue: Error): void; + + // Receives a completion notification + complete(): void; + } + + type SubscriberFunction = ( + observer: SubscriptionObserver + ) => void | null | undefined | (() => void) | Subscription; + + class Observable { + constructor(subscriber: SubscriberFunction); + + // Subscribes to the sequence with an observer + subscribe(): Subscription; + subscribe(observer: PartialObserver): Subscription; + + // Subscribes to the sequence with callbacks + subscribe( + onNext: (val: T) => void, + onError?: (err: Error) => void, + onComplete?: () => void + ): Subscription; + + // Returns itself + [Symbol.observable](): Observable; + + static of(...items: T[]): Observable; + static from(x: ObservableInput): Observable; + } +} + +export = Observable; diff --git a/src/core/lib/kbn_internal_native_observable/index.js b/src/core/lib/kbn_internal_native_observable/index.js new file mode 100644 index 00000000000000..4b6a56c9062f0d --- /dev/null +++ b/src/core/lib/kbn_internal_native_observable/index.js @@ -0,0 +1,322 @@ +import symbolObservable from 'symbol-observable'; + +// This is a fork of the example implementation of the TC39 Observable spec, +// see https://github.com/tc39/proposal-observable. +// +// One change has been applied to work with current libraries: using the +// Symbol.observable ponyfill instead of relying on the implementation in the +// spec. + +// === Abstract Operations === + +function nonEnum(obj) { + + Object.getOwnPropertyNames(obj).forEach(k => { + Object.defineProperty(obj, k, { enumerable: false }); + }); + + return obj; +} + +function getMethod(obj, key) { + + let value = obj[key]; + + if (value == null) + return undefined; + + if (typeof value !== "function") + throw new TypeError(value + " is not a function"); + + return value; +} + +function cleanupSubscription(subscription) { + + // Assert: observer._observer is undefined + + let cleanup = subscription._cleanup; + + if (!cleanup) + return; + + // Drop the reference to the cleanup function so that we won't call it + // more than once + subscription._cleanup = undefined; + + // Call the cleanup function + try { + cleanup(); + } + catch(e) { + // HostReportErrors(e); + } +} + +function subscriptionClosed(subscription) { + + return subscription._observer === undefined; +} + +function closeSubscription(subscription) { + + if (subscriptionClosed(subscription)) + return; + + subscription._observer = undefined; + cleanupSubscription(subscription); +} + +function cleanupFromSubscription(subscription) { + return _=> { subscription.unsubscribe() }; +} + +function Subscription(observer, subscriber) { + // Assert: subscriber is callable + // The observer must be an object + this._cleanup = undefined; + this._observer = observer; + + // If the observer has a start method, call it with the subscription object + try { + let start = getMethod(observer, "start"); + + if (start) { + start.call(observer, this); + } + } + catch(e) { + // HostReportErrors(e); + } + + // If the observer has unsubscribed from the start method, exit + if (subscriptionClosed(this)) + return; + + observer = new SubscriptionObserver(this); + + try { + + // Call the subscriber function + let cleanup = subscriber.call(undefined, observer); + + // The return value must be undefined, null, a subscription object, or a function + if (cleanup != null) { + if (typeof cleanup.unsubscribe === "function") + cleanup = cleanupFromSubscription(cleanup); + else if (typeof cleanup !== "function") + throw new TypeError(cleanup + " is not a function"); + + this._cleanup = cleanup; + } + + } catch (e) { + + // If an error occurs during startup, then send the error + // to the observer. + observer.error(e); + return; + } + + // If the stream is already finished, then perform cleanup + if (subscriptionClosed(this)) { + cleanupSubscription(this); + } +} + +Subscription.prototype = nonEnum({ + get closed() { return subscriptionClosed(this) }, + unsubscribe() { closeSubscription(this) }, +}); + +function SubscriptionObserver(subscription) { + this._subscription = subscription; +} + +SubscriptionObserver.prototype = nonEnum({ + + get closed() { + + return subscriptionClosed(this._subscription); + }, + + next(value) { + + let subscription = this._subscription; + + // If the stream if closed, then return undefined + if (subscriptionClosed(subscription)) + return undefined; + + let observer = subscription._observer; + + try { + let m = getMethod(observer, "next"); + + // If the observer doesn't support "next", then return undefined + if (!m) + return undefined; + + // Send the next value to the sink + m.call(observer, value); + } + catch(e) { + // HostReportErrors(e); + } + return undefined; + }, + + error(value) { + + let subscription = this._subscription; + + // If the stream is closed, throw the error to the caller + if (subscriptionClosed(subscription)) { + return undefined; + } + + let observer = subscription._observer; + subscription._observer = undefined; + + try { + + let m = getMethod(observer, "error"); + + // If the sink does not support "complete", then return undefined + if (m) { + m.call(observer, value); + } + else { + // HostReportErrors(e); + } + } catch (e) { + // HostReportErrors(e); + } + + cleanupSubscription(subscription); + + return undefined; + }, + + complete() { + + let subscription = this._subscription; + + // If the stream is closed, then return undefined + if (subscriptionClosed(subscription)) + return undefined; + + let observer = subscription._observer; + subscription._observer = undefined; + + try { + + let m = getMethod(observer, "complete"); + + // If the sink does not support "complete", then return undefined + if (m) { + m.call(observer); + } + } catch (e) { + // HostReportErrors(e); + } + + cleanupSubscription(subscription); + + return undefined; + }, + +}); + +export class Observable { + + // == Fundamental == + + constructor(subscriber) { + + // The stream subscriber must be a function + if (typeof subscriber !== "function") + throw new TypeError("Observable initializer must be a function"); + + this._subscriber = subscriber; + } + + subscribe(observer, ...args) { + if (typeof observer === "function") { + observer = { + next: observer, + error: args[0], + complete: args[1] + }; + } + else if (typeof observer !== "object") { + observer = {}; + } + + return new Subscription(observer, this._subscriber); + } + + [symbolObservable]() { return this } + + // == Derived == + + static from(x) { + + let C = typeof this === "function" ? this : Observable; + + if (x == null) + throw new TypeError(x + " is not an object"); + + let method = getMethod(x, symbolObservable); + + if (method) { + + let observable = method.call(x); + + if (Object(observable) !== observable) + throw new TypeError(observable + " is not an object"); + + if (observable.constructor === C) + return observable; + + return new C(observer => observable.subscribe(observer)); + } + + method = getMethod(x, Symbol.iterator); + + if (!method) + throw new TypeError(x + " is not observable"); + + return new C(observer => { + + for (let item of method.call(x)) { + + observer.next(item); + + if (observer.closed) + return; + } + + observer.complete(); + }); + } + + static of(...items) { + + let C = typeof this === "function" ? this : Observable; + + return new C(observer => { + + for (let i = 0; i < items.length; ++i) { + + observer.next(items[i]); + + if (observer.closed) + return; + } + + observer.complete(); + }); + } + +} diff --git a/src/core/lib/kbn_observable/README.md b/src/core/lib/kbn_observable/README.md new file mode 100644 index 00000000000000..4575a941b7ee73 --- /dev/null +++ b/src/core/lib/kbn_observable/README.md @@ -0,0 +1,138 @@ +# `kbn-observable` + +kbn-observable is an observable library based on the [proposed `Observable`][proposal] +feature. In includes several factory functions and operators, that all return +"native" observable. + +Why build this? The main reason is that we don't want to tie our plugin apis +heavily to a large dependency, but rather expose something that's much closer +to "native" observables, and something we have control over ourselves. Also, all +other observable libraries have their own base `Observable` class, while we +wanted to rely on the proposed functionality. + +In addition, kbn-observable includes `System.observable`, which enables interop +between observable libraries, which means plugins can use whatever observable +library they want, if they don't want to use `kbn-observable`. + +## Example + +```js +import { Observable, k$, map, last } from '../kbn_observable'; + +const source = Observable.of(1, 2, 3); + +// When `k$` is called with the source observable it returns a function that +// can be called with "operators" that modify the input value and return an +// observable that reflects all of the modifications. +k$(source)(map(i => 2017 + i), last()) + .subscribe(console.log) // logs 2020 +``` + +## Just getting started with Observables? + +If you are just getting started with observables, a great place to start is with +Andre Staltz' [The introduction to Reactive Programming you've been missing][staltz-intro], +which is a great introduction to the ideas and concepts. + +The ideas in `kbn-observable` is heavily based on [RxJS][rxjs], so the +[RxJS docs][rxjs-docs] are also a good source of introduction to observables and +how they work in this library. + +**NOTE**: Do you know about good articles, videos or other resources that does +a great job at explaining observables? Add them here, so it becomes easier for +the next person to learn about them! + +## Factories + +Just like the `k$` function, factories take arguments and produce an observable. +Different factories are useful for different things, and many behave just like +the static functions attached to the `Rx.Observable` class in RxJS. + +See [./src/factories](./src/factories) for more info about each factory. + +## Operators + +Operators are functions that take some arguments and produce an operator +function. Operators aren't anything fancy, just a function that takes an +observable and returns a new observable with the requested modifications +applied. + +Some examples: + +```js +map(i => 2017 + i); + +filter(i => i % 2 === 0) + +reduce((acc, val) => { + return acc + val; +}, 0); +``` + +Multiple operator functions can be passed to `k$` and will be applied to the +input observable before returning the final observable with all modifications +applied, e.g. like the example above with `map` and `last`. + +See [./src/operators](./src/operators) for more info about each operator. + +## More advanced topics + +This library contains implementations of both `Observable` and `Subject`. To +better understand the difference between them, it's important to understand the +difference between hot and cold observables. Ben Lesh's +[Hot vs Cold Observables][hot-vs-cold] is a great introduction to this topic. + +**NOTE**: Do you know about good articles, videos or other resources that goes +deeper into Observables and related topics? Make sure we get them added to this +list! + +## Why `kbn-observable`? + +While exploring how to handle observables in Kibana we went through multiple +PoCs. We initially used RxJS directly, but we didn't find a simple way to +consistently transform RxJS observables into "native" observables in the plugin +apis. This was something we wanted because of our earlier experiences with +exposing large libraries in our apis, which causes problems e.g. when we need to +perform major upgrades of a lib that has breaking changes, but we can't ship a +new major version of Kibana yet, even though this will cause breaking changes +in our plugin apis. + +Then we built the initial version of `kbn-observable` based on the Observable +spec, and we included the `k$` helper and several operators that worked like +this: + +```js +import { k$, Observable, map, first } from 'kbn-observable'; + +// general structure: +const resultObservable = k$(sourceObservable, [...operators]); + +// e.g. +const source = Observable.of(1,2,3); +const observable = k$(source, [map(x => x + 1), first()]); +``` + +Here `Observable` is a copy of the Observable class from the spec. This +would enable us to always work with these spec-ed observables. This api for `k$` +worked nicely in pure JavaScript, but caused a problem with TypeScript, as +TypeScript wasn't able to correctly type the operators array when more than one +operator was specified. + +Because of that problem we ended up with `k$(source)(...operators)`. With this +change TypeScript is able to correctly type the operator arguments. + +We've also discussed adding a `pipe` method to the `Observable.prototype`, so we +could do `source.pipe(...operators)` instead, but we decided against it because +we didn't want to start adding features directly on the `Observable` object, but +rather follow the spec as close as possible, and only update whenever the spec +receives updates. + +## Inspiration + +This code is heavily inspired by and based on RxJS, which is licensed under the +Apache License, Version 2.0, see https://github.com/ReactiveX/rxjs. + +[proposal]: https://github.com/tc39/proposal-observable +[rxjs]: http://reactivex.io/rxjs/ +[rxjs-docs]: http://reactivex.io/rxjs/manual/index.html +[staltz-intro]: https://gist.github.com/staltz/868e7e9bc2a7b8c1f754 \ No newline at end of file diff --git a/src/core/lib/kbn_observable/__tests__/__snapshots__/behavior_subject.test.ts.snap b/src/core/lib/kbn_observable/__tests__/__snapshots__/behavior_subject.test.ts.snap new file mode 100644 index 00000000000000..b3317da265859e --- /dev/null +++ b/src/core/lib/kbn_observable/__tests__/__snapshots__/behavior_subject.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should throw if it has received an error and getValue() is called 1`] = `"derp"`; diff --git a/src/core/lib/kbn_observable/__tests__/behavior_subject.test.ts b/src/core/lib/kbn_observable/__tests__/behavior_subject.test.ts new file mode 100644 index 00000000000000..b3faa16cd35f9f --- /dev/null +++ b/src/core/lib/kbn_observable/__tests__/behavior_subject.test.ts @@ -0,0 +1,186 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BehaviorSubject } from '../behavior_subject'; +import { collect } from '../lib/collect'; +import { Observable } from '../observable'; +import { Subject } from '../subject'; + +test('should extend Subject', () => { + const subject = new BehaviorSubject(null); + expect(subject).toBeInstanceOf(Subject); +}); + +test('should throw if it has received an error and getValue() is called', () => { + const subject = new BehaviorSubject(null); + + subject.error(new Error('derp')); + + expect(() => { + subject.getValue(); + }).toThrowErrorMatchingSnapshot(); +}); + +test('should have a getValue() method to retrieve the current value', () => { + const subject = new BehaviorSubject('foo'); + + expect(subject.getValue()).toEqual('foo'); + + subject.next('bar'); + + expect(subject.getValue()).toEqual('bar'); +}); + +test('should not update value after completed', () => { + const subject = new BehaviorSubject('foo'); + + expect(subject.getValue()).toEqual('foo'); + + subject.next('bar'); + subject.complete(); + subject.next('quux'); + + expect(subject.getValue()).toEqual('bar'); +}); + +test('should start with an initialization value', async () => { + const subject = new BehaviorSubject('foo'); + const res = collect(subject); + + subject.next('bar'); + subject.complete(); + + expect(await res).toEqual(['foo', 'bar', 'C']); +}); + +test('should pump values to multiple subscribers', async () => { + const subject = new BehaviorSubject('init'); + const expected = ['init', 'foo', 'bar', 'C']; + + const res1 = collect(subject); + const res2 = collect(subject); + + expect((subject as any).observers.size).toEqual(2); + subject.next('foo'); + subject.next('bar'); + subject.complete(); + + expect(await res1).toEqual(expected); + expect(await res2).toEqual(expected); +}); + +test('should not pass values nexted after a complete', () => { + const subject = new BehaviorSubject('init'); + const results: any[] = []; + + subject.subscribe(x => { + results.push(x); + }); + expect(results).toEqual(['init']); + + subject.next('foo'); + expect(results).toEqual(['init', 'foo']); + + subject.complete(); + expect(results).toEqual(['init', 'foo']); + + subject.next('bar'); + expect(results).toEqual(['init', 'foo']); +}); + +test('should clean out unsubscribed subscribers', () => { + const subject = new BehaviorSubject('init'); + + const sub1 = subject.subscribe(); + const sub2 = subject.subscribe(); + + expect((subject as any).observers.size).toEqual(2); + + sub1.unsubscribe(); + expect((subject as any).observers.size).toEqual(1); + + sub2.unsubscribe(); + expect((subject as any).observers.size).toEqual(0); +}); + +test('should replay the previous value when subscribed', () => { + const subject = new BehaviorSubject(0); + + subject.next(1); + subject.next(2); + + const s1Actual: number[] = []; + const s1 = subject.subscribe(x => { + s1Actual.push(x); + }); + + subject.next(3); + subject.next(4); + + const s2Actual: number[] = []; + const s2 = subject.subscribe(x => { + s2Actual.push(x); + }); + + s1.unsubscribe(); + + subject.next(5); + + const s3Actual: number[] = []; + const s3 = subject.subscribe(x => { + s3Actual.push(x); + }); + + s2.unsubscribe(); + s3.unsubscribe(); + + subject.complete(); + + expect(s1Actual).toEqual([2, 3, 4]); + expect(s2Actual).toEqual([4, 5]); + expect(s3Actual).toEqual([5]); +}); + +test('should emit complete when subscribed after completed', () => { + const source = Observable.of(1, 2, 3, 4, 5); + const subject = new BehaviorSubject(0); + + const next = jest.fn(); + const complete = jest.fn(); + + subject.complete(); + + subject.subscribe(next, undefined, complete); + source.subscribe(subject); + + expect(next).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalledTimes(1); +}); + +test('should be an Observer which can be given to Observable.subscribe', async () => { + const source = Observable.of(1, 2, 3, 4, 5); + const subject = new BehaviorSubject(0); + + const res = collect(subject); + + source.subscribe(subject); + + expect(await res).toEqual([0, 1, 2, 3, 4, 5, 'C']); + expect(subject.getValue()).toBe(5); +}); diff --git a/src/core/lib/kbn_observable/__tests__/k.test.ts b/src/core/lib/kbn_observable/__tests__/k.test.ts new file mode 100644 index 00000000000000..408b2cbf57e75a --- /dev/null +++ b/src/core/lib/kbn_observable/__tests__/k.test.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { MonoTypeOperatorFunction, OperatorFunction, UnaryFunction } from '../interfaces'; +import { k$ } from '../k'; +import { Observable } from '../observable'; + +const plus1: MonoTypeOperatorFunction = source => + new Observable(observer => { + source.subscribe({ + next(val) { + observer.next(val + 1); + }, + error(err) { + observer.error(err); + }, + complete() { + observer.complete(); + }, + }); + }); + +const toString: OperatorFunction = source => + new Observable(observer => { + source.subscribe({ + next(val) { + observer.next(val.toString()); + }, + error(err) { + observer.error(err); + }, + complete() { + observer.complete(); + }, + }); + }); + +const toPromise: UnaryFunction, Promise> = source => + new Promise((resolve, reject) => { + let lastValue: number; + + source.subscribe({ + next(value) { + lastValue = value; + }, + error(error) { + reject(error); + }, + complete() { + resolve(lastValue); + }, + }); + }); + +test('observable to observable', () => { + const numbers$ = Observable.of(1, 2, 3); + const actual: any[] = []; + + k$(numbers$)(plus1, toString).subscribe({ + next(x) { + actual.push(x); + }, + }); + + expect(actual).toEqual(['2', '3', '4']); +}); + +test('observable to promise', async () => { + const numbers$ = Observable.of(1, 2, 3); + + const value = await k$(numbers$)(plus1, toPromise); + + expect(value).toEqual(4); +}); diff --git a/src/core/lib/kbn_observable/__tests__/observable.test.ts b/src/core/lib/kbn_observable/__tests__/observable.test.ts new file mode 100644 index 00000000000000..64492a7f2fdb87 --- /dev/null +++ b/src/core/lib/kbn_observable/__tests__/observable.test.ts @@ -0,0 +1,160 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable, SubscriptionObserver } from '../observable'; + +test('receives values when subscribed', async () => { + let observer: SubscriptionObserver; + + const source = new Observable(innerObservable => { + observer = innerObservable; + }); + + const res: any[] = []; + + source.subscribe({ + next(x) { + res.push(x); + }, + }); + + observer!.next('foo'); + expect(res).toEqual(['foo']); + + observer!.next('bar'); + expect(res).toEqual(['foo', 'bar']); +}); + +test('triggers complete when observer is completed', async () => { + let observer: SubscriptionObserver; + + const source = new Observable(innerObservable => { + observer = innerObservable; + }); + + const complete = jest.fn(); + + source.subscribe({ + complete, + }); + + observer!.complete(); + + expect(complete).toHaveBeenCalledTimes(1); +}); + +test('should send errors thrown in the constructor down the error path', async () => { + const err = new Error('this should be handled'); + + const source = new Observable(observer => { + throw err; + }); + + const error = jest.fn(); + + source.subscribe({ + error, + }); + + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith(err); +}); + +describe('subscriptions', () => { + test('handles multiple subscriptions and unsubscriptions', () => { + let observers = 0; + + const source = new Observable(observer => { + observers++; + + return () => { + observers--; + }; + }); + + const sub1 = source.subscribe(); + expect(observers).toBe(1); + + const sub2 = source.subscribe(); + expect(observers).toBe(2); + + sub1.unsubscribe(); + expect(observers).toBe(1); + + sub2.unsubscribe(); + expect(observers).toBe(0); + }); +}); + +describe('Observable.from', () => { + test('handles array', () => { + const res: number[] = []; + const complete = jest.fn(); + + Observable.from([1, 2, 3]).subscribe({ + next(x) { + res.push(x); + }, + complete, + }); + + expect(complete).toHaveBeenCalledTimes(1); + expect(res).toEqual([1, 2, 3]); + }); + + test('handles iterable', () => { + const fooIterable: Iterable = { + *[Symbol.iterator]() { + yield 1; + yield 2; + yield 3; + }, + }; + + const res: number[] = []; + const complete = jest.fn(); + + Observable.from(fooIterable).subscribe({ + next(x) { + res.push(x); + }, + complete, + }); + + expect(complete).toHaveBeenCalledTimes(1); + expect(res).toEqual([1, 2, 3]); + }); +}); + +describe('Observable.of', () => { + test('handles multiple args', () => { + const res: number[] = []; + const complete = jest.fn(); + + Observable.of(1, 2, 3).subscribe({ + next(x) { + res.push(x); + }, + complete, + }); + + expect(complete).toHaveBeenCalledTimes(1); + expect(res).toEqual([1, 2, 3]); + }); +}); diff --git a/src/core/lib/kbn_observable/__tests__/subject.test.ts b/src/core/lib/kbn_observable/__tests__/subject.test.ts new file mode 100644 index 00000000000000..ad19223d15dfc0 --- /dev/null +++ b/src/core/lib/kbn_observable/__tests__/subject.test.ts @@ -0,0 +1,508 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { k$ } from '../k'; +import { Observable } from '../observable'; +import { first } from '../operators'; +import { Subject } from '../subject'; + +const noop = () => { + // noop +}; + +test('should pump values right on through itself', () => { + const subject = new Subject(); + const actual: string[] = []; + + subject.subscribe(x => { + actual.push(x); + }); + + subject.next('foo'); + subject.next('bar'); + subject.complete(); + + expect(actual).toEqual(['foo', 'bar']); +}); + +test('should pump values to multiple subscribers', () => { + const subject = new Subject(); + const actual: string[] = []; + + subject.subscribe(x => { + actual.push(`1-${x}`); + }); + + subject.subscribe(x => { + actual.push(`2-${x}`); + }); + + expect((subject as any).observers.size).toEqual(2); + subject.next('foo'); + subject.next('bar'); + subject.complete(); + + expect(actual).toEqual(['1-foo', '2-foo', '1-bar', '2-bar']); +}); + +test('should handle subscribers that arrive and leave at different times, subject does not complete', () => { + const subject = new Subject(); + const results1: any[] = []; + const results2: any[] = []; + const results3: any[] = []; + + subject.next(1); + subject.next(2); + subject.next(3); + subject.next(4); + + const subscription1 = subject.subscribe( + x => { + results1.push(x); + }, + e => { + results1.push('E'); + }, + () => { + results1.push('C'); + } + ); + + subject.next(5); + + const subscription2 = subject.subscribe( + x => { + results2.push(x); + }, + e => { + results2.push('E'); + }, + () => { + results2.push('C'); + } + ); + + subject.next(6); + subject.next(7); + + subscription1.unsubscribe(); + + subject.next(8); + + subscription2.unsubscribe(); + + subject.next(9); + subject.next(10); + + const subscription3 = subject.subscribe( + x => { + results3.push(x); + }, + e => { + results3.push('E'); + }, + () => { + results3.push('C'); + } + ); + + subject.next(11); + + subscription3.unsubscribe(); + + expect(results1).toEqual([5, 6, 7]); + expect(results2).toEqual([6, 7, 8]); + expect(results3).toEqual([11]); +}); + +test('should handle subscribers that arrive and leave at different times, subject completes', () => { + const subject = new Subject(); + const results1: any[] = []; + const results2: any[] = []; + const results3: any[] = []; + + subject.next(1); + subject.next(2); + subject.next(3); + subject.next(4); + + const subscription1 = subject.subscribe( + x => { + results1.push(x); + }, + e => { + results1.push('E'); + }, + () => { + results1.push('C'); + } + ); + + subject.next(5); + + const subscription2 = subject.subscribe( + x => { + results2.push(x); + }, + e => { + results2.push('E'); + }, + () => { + results2.push('C'); + } + ); + + subject.next(6); + subject.next(7); + + subscription1.unsubscribe(); + + subject.complete(); + + subscription2.unsubscribe(); + + const subscription3 = subject.subscribe( + x => { + results3.push(x); + }, + e => { + results3.push('E'); + }, + () => { + results3.push('C'); + } + ); + + subscription3.unsubscribe(); + + expect(results1).toEqual([5, 6, 7]); + expect(results2).toEqual([6, 7, 'C']); + expect(results3).toEqual(['C']); +}); + +test('should handle subscribers that arrive and leave at different times, subject terminates with an error', () => { + const subject = new Subject(); + const results1: any[] = []; + const results2: any[] = []; + const results3: any[] = []; + + subject.next(1); + subject.next(2); + subject.next(3); + subject.next(4); + + const subscription1 = subject.subscribe( + x => { + results1.push(x); + }, + e => { + results1.push('E'); + }, + () => { + results1.push('C'); + } + ); + + subject.next(5); + + const subscription2 = subject.subscribe( + x => { + results2.push(x); + }, + e => { + results2.push('E'); + }, + () => { + results2.push('C'); + } + ); + + subject.next(6); + subject.next(7); + + subscription1.unsubscribe(); + + subject.error(new Error('err')); + + subscription2.unsubscribe(); + + const subscription3 = subject.subscribe( + x => { + results3.push(x); + }, + e => { + results3.push('E'); + }, + () => { + results3.push('C'); + } + ); + + subscription3.unsubscribe(); + + expect(results1).toEqual([5, 6, 7]); + expect(results2).toEqual([6, 7, 'E']); + expect(results3).toEqual(['E']); +}); + +test('should handle subscribers that arrive and leave at different times, subject completes before nexting any value', () => { + const subject = new Subject(); + const results1: any[] = []; + const results2: any[] = []; + const results3: any[] = []; + + const subscription1 = subject.subscribe( + x => { + results1.push(x); + }, + e => { + results1.push('E'); + }, + () => { + results1.push('C'); + } + ); + + const subscription2 = subject.subscribe( + x => { + results2.push(x); + }, + e => { + results2.push('E'); + }, + () => { + results2.push('C'); + } + ); + + subscription1.unsubscribe(); + + subject.complete(); + + subscription2.unsubscribe(); + + const subscription3 = subject.subscribe( + x => { + results3.push(x); + }, + e => { + results3.push('E'); + }, + () => { + results3.push('C'); + } + ); + + subscription3.unsubscribe(); + + expect(results1).toEqual([]); + expect(results2).toEqual(['C']); + expect(results3).toEqual(['C']); +}); + +test('should clean out unsubscribed subscribers', () => { + const subject = new Subject(); + + const sub1 = subject.subscribe(noop); + const sub2 = subject.subscribe(noop); + + expect((subject as any).observers.size).toBe(2); + + sub1.unsubscribe(); + expect((subject as any).observers.size).toBe(1); + + sub2.unsubscribe(); + expect((subject as any).observers.size).toBe(0); +}); + +test('should be an Observer which can be given to Observable.subscribe', () => { + const source = Observable.of(1, 2, 3, 4, 5); + const subject = new Subject(); + const actual: number[] = []; + + const err = jest.fn(); + const complete = jest.fn(); + + subject.subscribe( + x => { + actual.push(x); + }, + err, + complete + ); + + source.subscribe(subject); + + expect(actual).toEqual([1, 2, 3, 4, 5]); + expect(err).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalledTimes(1); +}); + +test('can use subject in $k', async () => { + const values$ = new Subject(); + + const next = jest.fn(); + const complete = jest.fn(); + const error = jest.fn(); + + k$(values$)(first()).subscribe({ + complete, + error, + next, + }); + + values$.next('test'); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith('test'); + expect(error).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalled(); +}); + +test('should not next after completed', () => { + const subject = new Subject(); + const results: any[] = []; + + subject.subscribe(x => results.push(x), undefined, () => results.push('C')); + + subject.next('a'); + subject.complete(); + subject.next('b'); + + expect(results).toEqual(['a', 'C']); +}); + +test('should not next after error', () => { + const error = new Error('wut?'); + const subject = new Subject(); + const results: any[] = []; + + subject.subscribe(x => results.push(x), err => results.push(err)); + + subject.next('a'); + subject.error(error); + subject.next('b'); + + expect(results).toEqual(['a', error]); +}); + +describe('asObservable', () => { + test('should hide subject', () => { + const subject = new Subject(); + const observable = subject.asObservable(); + + expect(subject).not.toBe(observable); + + expect(observable).toBeInstanceOf(Observable); + expect(observable).not.toBeInstanceOf(Subject); + }); + + test('should handle subject completes without emits', () => { + const subject = new Subject(); + + const complete = jest.fn(); + + subject.asObservable().subscribe({ + complete, + }); + + subject.complete(); + + expect(complete).toHaveBeenCalledTimes(1); + }); + + test('should handle subject throws', () => { + const subject = new Subject(); + + const error = jest.fn(); + + subject.asObservable().subscribe({ + error, + }); + + const e = new Error('yep'); + subject.error(e); + + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith(e); + }); + + test('should handle subject emits', () => { + const subject = new Subject(); + + const actual: number[] = []; + + subject.asObservable().subscribe({ + next(x) { + actual.push(x); + }, + }); + + subject.next(1); + subject.next(2); + subject.complete(); + + expect(actual).toEqual([1, 2]); + }); + + test('can unsubscribe', () => { + const subject = new Subject(); + + const actual: number[] = []; + + const sub = subject.asObservable().subscribe({ + next(x) { + actual.push(x); + }, + }); + + subject.next(1); + + sub.unsubscribe(); + + subject.next(2); + subject.complete(); + + expect(actual).toEqual([1]); + }); + + test('should handle multiple observables', () => { + const subject = new Subject(); + + const actual: string[] = []; + + subject.asObservable().subscribe({ + next(x) { + actual.push(`1-${x}`); + }, + }); + + subject.asObservable().subscribe({ + next(x) { + actual.push(`2-${x}`); + }, + }); + + subject.next('foo'); + subject.next('bar'); + subject.complete(); + + expect(actual).toEqual(['1-foo', '2-foo', '1-bar', '2-bar']); + }); +}); diff --git a/src/core/lib/kbn_observable/behavior_subject.ts b/src/core/lib/kbn_observable/behavior_subject.ts new file mode 100644 index 00000000000000..fb901e53309ec3 --- /dev/null +++ b/src/core/lib/kbn_observable/behavior_subject.ts @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SubscriptionObserver } from './observable'; +import { Subject } from './subject'; + +/** + * A BehaviorSubject is a Subject that has a _current_ value. + * + * Whenever an observer subscribes to a BehaviorSubject, it begins by emitting + * the item most recently emitted by the source Observable (or a seed/default + * value if none has yet been emitted) and then continues to emit any other + * items emitted later by the source Observable(s). + */ +export class BehaviorSubject extends Subject { + constructor(private value: T) { + super(); + } + + /** + * @returns The current value of the BehaviorSubject. Most of the time this + * shouldn't be used directly, but there are situations were it can come in + * handy. Usually a BehaviorSubject is used so you immediately receive the + * latest/current value when subscribing. + */ + public getValue() { + if (this.thrownError !== undefined) { + throw this.thrownError; + } + + return this.value; + } + + public next(value: T) { + if (!this.isStopped) { + this.value = value; + } + return super.next(value); + } + + protected registerObserver(observer: SubscriptionObserver) { + if (!this.isStopped) { + observer.next(this.value); + } + return super.registerObserver(observer); + } +} diff --git a/src/core/lib/kbn_observable/errors/empty_error.ts b/src/core/lib/kbn_observable/errors/empty_error.ts new file mode 100644 index 00000000000000..24070ae1f70378 --- /dev/null +++ b/src/core/lib/kbn_observable/errors/empty_error.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export class EmptyError extends Error { + public code = 'K$_EMPTY_ERROR'; + + constructor(producer: string) { + super(`EmptyError: ${producer} requires source stream to emit at least one value.`); + + // We're forching this to `any` as `captureStackTrace` is not a standard + // property, but a v8 specific one. There are node typings that we might + // want to use, see https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/index.d.ts#L47 + (Error as any).captureStackTrace(this, EmptyError); + } +} diff --git a/src/core/lib/kbn_observable/errors/index.ts b/src/core/lib/kbn_observable/errors/index.ts new file mode 100644 index 00000000000000..4eb513102126c0 --- /dev/null +++ b/src/core/lib/kbn_observable/errors/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { EmptyError } from './empty_error'; diff --git a/src/core/lib/kbn_observable/factories/__tests__/__snapshots__/bind_node_callback.test.ts.snap b/src/core/lib/kbn_observable/factories/__tests__/__snapshots__/bind_node_callback.test.ts.snap new file mode 100644 index 00000000000000..fea1bb1b21aaf9 --- /dev/null +++ b/src/core/lib/kbn_observable/factories/__tests__/__snapshots__/bind_node_callback.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`errors if callback is called with more than two args 1`] = ` +Array [ + [Error: Node callback called with too many args], +] +`; diff --git a/src/core/lib/kbn_observable/factories/__tests__/bind_node_callback.test.ts b/src/core/lib/kbn_observable/factories/__tests__/bind_node_callback.test.ts new file mode 100644 index 00000000000000..9aa3ace3bc3b0b --- /dev/null +++ b/src/core/lib/kbn_observable/factories/__tests__/bind_node_callback.test.ts @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { collect } from '../../lib/collect'; +import { $bindNodeCallback } from '../bind_node_callback'; + +type NodeCallback = (err: any, val?: string) => void; + +test('callback with error', async () => { + const error = new Error('fail'); + const read = (cb: NodeCallback) => cb(error); + + const read$ = $bindNodeCallback(read); + const res = collect(read$()); + + expect(await res).toEqual([error]); +}); + +test('callback with value', async () => { + const read = (cb: NodeCallback) => cb(undefined, 'test'); + + const read$ = $bindNodeCallback(read); + const res = collect(read$()); + + expect(await res).toEqual(['test', 'C']); +}); + +test('does not treat `null` as error', async () => { + const read = (cb: NodeCallback) => cb(null, 'test'); + + const read$ = $bindNodeCallback(read); + const res = collect(read$()); + + expect(await res).toEqual(['test', 'C']); +}); + +test('multiple args', async () => { + const read = (arg1: string, arg2: number, cb: NodeCallback) => cb(undefined, `${arg1}/${arg2}`); + + const read$ = $bindNodeCallback(read); + const res = collect(read$('foo', 123)); + + expect(await res).toEqual(['foo/123', 'C']); +}); + +test('function throws instead of calling callback', async () => { + const error = new Error('fail'); + + const read = (cb: NodeCallback) => { + throw error; + }; + + const read$ = $bindNodeCallback(read); + const res = collect(read$()); + + expect(await res).toEqual([error]); +}); + +test('errors if callback is called with more than two args', async () => { + const read = (cb: (...args: any[]) => any) => cb(undefined, 'arg1', 'arg2'); + + const read$ = $bindNodeCallback(read); + const res = collect(read$()); + + expect(await res).toMatchSnapshot(); +}); diff --git a/src/core/lib/kbn_observable/factories/__tests__/combine_latest.test.ts b/src/core/lib/kbn_observable/factories/__tests__/combine_latest.test.ts new file mode 100644 index 00000000000000..e2691c2ab97cde --- /dev/null +++ b/src/core/lib/kbn_observable/factories/__tests__/combine_latest.test.ts @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { $combineLatest, $of } from '../../factories'; +import { collect } from '../../lib/collect'; +import { Subject } from '../../subject'; + +const tickMs = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +test('emits once for each combination of items', async () => { + const foo$ = new Subject(); + const bar$ = new Subject(); + + const observable = $combineLatest(foo$, bar$); + const res = collect(observable); + + await tickMs(10); + bar$.next('a'); + + await tickMs(5); + foo$.next(1); + + await tickMs(5); + bar$.next('b'); + + await tickMs(5); + foo$.next(2); + bar$.next('c'); + + await tickMs(10); + foo$.next(3); + + bar$.complete(); + foo$.complete(); + + expect(await res).toEqual([[1, 'a'], [1, 'b'], [2, 'b'], [2, 'c'], [3, 'c'], 'C']); +}); + +test('only emits if every stream emits at least once', async () => { + const empty$ = $of(); + const three$ = $of(1, 2, 3); + + const observable = $combineLatest(empty$, three$); + const res = collect(observable); + + expect(await res).toEqual(['C']); +}); diff --git a/src/core/lib/kbn_observable/factories/__tests__/concat.test.ts b/src/core/lib/kbn_observable/factories/__tests__/concat.test.ts new file mode 100644 index 00000000000000..34d7fe1b09a36a --- /dev/null +++ b/src/core/lib/kbn_observable/factories/__tests__/concat.test.ts @@ -0,0 +1,73 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { $concat } from '../'; +import { collect } from '../../lib/collect'; +import { Subject } from '../../subject'; + +test('continous on next observable when previous completes', async () => { + const a = new Subject(); + const b = new Subject(); + + const observable = $concat(a, b); + const res = collect(observable); + + a.next('a1'); + b.next('b1'); + a.next('a2'); + a.complete(); + b.next('b2'); + b.complete(); + + expect(await res).toEqual(['a1', 'a2', 'b2', 'C']); +}); + +test('errors when any observable errors', async () => { + const a = new Subject(); + const b = new Subject(); + + const observable = $concat(a, b); + const res = collect(observable); + + const error = new Error('fail'); + a.next('a1'); + a.error(error); + + expect(await res).toEqual(['a1', error]); +}); + +test('handles early unsubscribe', () => { + const a = new Subject(); + const b = new Subject(); + + const next = jest.fn(); + const complete = jest.fn(); + const sub = $concat(a, b).subscribe({ next, complete }); + + a.next('a1'); + sub.unsubscribe(); + a.next('a2'); + a.complete(); + b.next('b1'); + b.complete(); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith('a1'); + expect(complete).toHaveBeenCalledTimes(0); +}); diff --git a/src/core/lib/kbn_observable/factories/__tests__/from.test.ts b/src/core/lib/kbn_observable/factories/__tests__/from.test.ts new file mode 100644 index 00000000000000..4082b9923329c3 --- /dev/null +++ b/src/core/lib/kbn_observable/factories/__tests__/from.test.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { $from } from '../../factories'; + +test('handles array', () => { + const res: number[] = []; + const complete = jest.fn(); + + $from([1, 2, 3]).subscribe({ + next(x) { + res.push(x); + }, + complete, + }); + + expect(complete).toHaveBeenCalledTimes(1); + expect(res).toEqual([1, 2, 3]); +}); + +test('handles iterable', () => { + const fooIterable: Iterable = { + *[Symbol.iterator]() { + yield 1; + yield 2; + yield 3; + }, + }; + + const res: number[] = []; + const complete = jest.fn(); + + $from(fooIterable).subscribe({ + next(x) { + res.push(x); + }, + complete, + }); + + expect(complete).toHaveBeenCalledTimes(1); + expect(res).toEqual([1, 2, 3]); +}); diff --git a/src/core/lib/kbn_observable/factories/__tests__/from_callback.test.ts b/src/core/lib/kbn_observable/factories/__tests__/from_callback.test.ts new file mode 100644 index 00000000000000..197a2505323a4e --- /dev/null +++ b/src/core/lib/kbn_observable/factories/__tests__/from_callback.test.ts @@ -0,0 +1,89 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { $from } from '../'; +import { collect } from '../../lib/collect'; +import { Subject } from '../../subject'; +import { $fromCallback } from '../from_callback'; + +test('returns raw value', async () => { + const observable = $fromCallback(() => 'foo'); + const res = collect(observable); + + expect(await res).toEqual(['foo', 'C']); +}); + +test('if undefined is returned, completes immediatley', async () => { + const observable = $fromCallback(() => undefined); + const res = collect(observable); + + expect(await res).toEqual(['C']); +}); + +test('if null is returned, forwards it', async () => { + const observable = $fromCallback(() => null); + const res = collect(observable); + + expect(await res).toEqual([null, 'C']); +}); + +test('returns observable that completes immediately', async () => { + const observable = $fromCallback(() => $from([1, 2, 3])); + const res = collect(observable); + + expect(await res).toEqual([1, 2, 3, 'C']); +}); + +test('returns observable that completes later', () => { + const subject = new Subject(); + + const next = jest.fn(); + const error = jest.fn(); + const complete = jest.fn(); + + $fromCallback(() => subject).subscribe(next, error, complete); + + expect(next).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(complete).not.toHaveBeenCalled(); + + subject.next('foo'); + expect(next).toHaveBeenCalledTimes(1); + expect(error).not.toHaveBeenCalled(); + expect(complete).not.toHaveBeenCalled(); + + subject.complete(); + expect(error).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalledTimes(1); +}); + +test('handles early unsubscribe', () => { + const subject = new Subject(); + + const next = () => { + // noop + }; + const sub = $fromCallback(() => subject).subscribe(next); + + subject.next('foo'); + + expect((subject as any).observers.size).toEqual(1); + sub.unsubscribe(); + expect((subject as any).observers.size).toEqual(0); +}); diff --git a/src/core/lib/kbn_observable/factories/bind_node_callback.ts b/src/core/lib/kbn_observable/factories/bind_node_callback.ts new file mode 100644 index 00000000000000..3514967853d04c --- /dev/null +++ b/src/core/lib/kbn_observable/factories/bind_node_callback.ts @@ -0,0 +1,100 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from '../observable'; + +export function $bindNodeCallback( + callbackFunc: (callback: (err: any, result: R) => any) => any +): () => Observable; +export function $bindNodeCallback( + callbackFunc: (v1: T, callback: (err: any, result: R) => any) => any +): (v1: T) => Observable; +export function $bindNodeCallback( + callbackFunc: (v1: T, v2: T2, callback: (err: any, result: R) => any) => any +): (v1: T, v2: T2) => Observable; +export function $bindNodeCallback( + callbackFunc: (v1: T, v2: T2, v3: T3, callback: (err: any, result: R) => any) => any +): (v1: T, v2: T2, v3: T3) => Observable; +export function $bindNodeCallback( + callbackFunc: (v1: T, v2: T2, v3: T3, v4: T4, callback: (err: any, result: R) => any) => any +): (v1: T, v2: T2, v3: T3, v4: T4) => Observable; +export function $bindNodeCallback( + callbackFunc: ( + v1: T, + v2: T2, + v3: T3, + v4: T4, + v5: T5, + callback: (err: any, result: R) => any + ) => any +): (v1: T, v2: T2, v3: T3, v4: T4, v5: T5) => Observable; +export function $bindNodeCallback( + callbackFunc: ( + v1: T, + v2: T2, + v3: T3, + v4: T4, + v5: T5, + v6: T6, + callback: (err: any, result: R) => any + ) => any +): (v1: T, v2: T2, v3: T3, v4: T4, v5: T5, v6: T6) => Observable; + +/** + * Converts a Node.js-style callback API to a function that returns an + * Observable. + * + * Does NOT handle functions whose callbacks have + * more than two parameters. Only the first value after the + * error argument will be returned. + * + * Example: Read a file from the filesystem and get the data as an Observable: + * + * import fs from 'fs'; + * var readFileAsObservable = $bindNodeCallback(fs.readFile); + * var result = readFileAsObservable('./roadNames.txt', 'utf8'); + * result.subscribe( + * x => console.log(x), + * e => console.error(e) + * ); + */ +export function $bindNodeCallback(callbackFunc: (...args: any[]) => any) { + return function(this: any, ...args: any[]): Observable { + const context = this; + + return new Observable(observer => { + function handlerFn(err?: Error, val?: T, ...rest: any[]) { + if (err != null) { + observer.error(err); + } else if (rest.length > 0) { + // If we've received more than two arguments, the function doesn't + // follow the common Node.js callback style. We could return an array + // if that happened, but as most code follow the pattern we don't + // special case it for now. + observer.error(new Error('Node callback called with too many args')); + } else { + observer.next(val!); + observer.complete(); + } + } + + callbackFunc.apply(context, args.concat([handlerFn])); + }); + }; +} diff --git a/src/core/lib/kbn_observable/factories/combine_latest.ts b/src/core/lib/kbn_observable/factories/combine_latest.ts new file mode 100644 index 00000000000000..e7b4071546316c --- /dev/null +++ b/src/core/lib/kbn_observable/factories/combine_latest.ts @@ -0,0 +1,113 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable, ObservableInput } from '../observable'; +import { $from } from './from'; + +const pending = Symbol('awaiting first value'); + +export function $combineLatest( + v1: ObservableInput, + v2: ObservableInput +): Observable<[T, T2]>; +export function $combineLatest( + v1: ObservableInput, + v2: ObservableInput, + v3: ObservableInput +): Observable<[T, T2, T3]>; +export function $combineLatest( + v1: ObservableInput, + v2: ObservableInput, + v3: ObservableInput, + v4: ObservableInput +): Observable<[T, T2, T3, T4]>; +export function $combineLatest( + v1: ObservableInput, + v2: ObservableInput, + v3: ObservableInput, + v4: ObservableInput, + v5: ObservableInput +): Observable<[T, T2, T3, T4, T5]>; +export function $combineLatest( + v1: ObservableInput, + v2: ObservableInput, + v3: ObservableInput, + v4: ObservableInput, + v5: ObservableInput, + v6: ObservableInput +): Observable<[T, T2, T3, T4, T5, T6]>; +export function $combineLatest(...observables: Array>): Observable; + +/** + * Creates an observable that combines the values by subscribing to all + * observables passed and emiting an array with the latest value from each + * observable once after each observable has emitted at least once, and again + * any time an observable emits after that. + * + * @param {Observable...} + * @return {Observable} + */ +export function $combineLatest(...observables: Array>): Observable { + return new Observable(observer => { + // create an array that will receive values as observables + // update and initialize it with `pending` symbols so that + // we know when observables emit for the first time + const values: Array = observables.map(() => pending); + + let needFirstCount = values.length; + let activeCount = values.length; + + const subs = observables.map((observable, i) => + $from(observable).subscribe({ + next(value) { + if (values[i] === pending) { + needFirstCount--; + } + + values[i] = value; + + if (needFirstCount === 0) { + observer.next(values.slice() as T[]); + } + }, + + error(error) { + observer.error(error); + values.length = 0; + }, + + complete() { + activeCount--; + + if (activeCount === 0) { + observer.complete(); + values.length = 0; + } + }, + }) + ); + + return () => { + subs.forEach(sub => { + sub.unsubscribe(); + }); + values.length = 0; + }; + }); +} diff --git a/src/core/lib/kbn_observable/factories/concat.ts b/src/core/lib/kbn_observable/factories/concat.ts new file mode 100644 index 00000000000000..b05ae6ea3214f5 --- /dev/null +++ b/src/core/lib/kbn_observable/factories/concat.ts @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable, Subscription } from '../observable'; + +/** + * Creates an observable that combines all observables passed as arguments into + * a single output observable by subscribing to them in series, i.e. it will + * subscribe to the next observable when the previous completes. + * + * @param {Observable...} + * @return {Observable} + */ +export function $concat(...observables: Array>) { + return new Observable(observer => { + let subscription: Subscription | undefined; + + function subscribe(i: number) { + if (observer.closed) { + return; + } + + if (i >= observables.length) { + observer.complete(); + } + + subscription = observables[i].subscribe({ + next(value) { + observer.next(value); + }, + error(error) { + observer.error(error); + }, + complete() { + subscribe(i + 1); + }, + }); + } + + subscribe(0); + + return () => { + if (subscription !== undefined) { + subscription.unsubscribe(); + } + }; + }); +} diff --git a/src/core/lib/kbn_observable/factories/error.ts b/src/core/lib/kbn_observable/factories/error.ts new file mode 100644 index 00000000000000..bc08e3a747514a --- /dev/null +++ b/src/core/lib/kbn_observable/factories/error.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from '../observable'; + +export function $error(error: E) { + return new Observable(observer => { + observer.error(error); + }); +} diff --git a/src/core/lib/kbn_observable/factories/from.ts b/src/core/lib/kbn_observable/factories/from.ts new file mode 100644 index 00000000000000..728ef95a941b87 --- /dev/null +++ b/src/core/lib/kbn_observable/factories/from.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable, ObservableInput } from '../observable'; + +/** + * Alias for `Observable.from` + * + * If you need to handle: + * + * - promises, use `$fromPromise` + * - functions, use `$fromCallback` + */ +export function $from(x: ObservableInput): Observable { + return Observable.from(x); +} diff --git a/src/core/lib/kbn_observable/factories/from_callback.ts b/src/core/lib/kbn_observable/factories/from_callback.ts new file mode 100644 index 00000000000000..306ee584e22c53 --- /dev/null +++ b/src/core/lib/kbn_observable/factories/from_callback.ts @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isObservable } from '../lib/is_observable'; +import { Observable } from '../observable'; + +/** + * Creates an observable that calls the specified function with no arguments + * when it is subscribed. The observerable will behave differently based on the + * return value of the factory: + * + * - return `undefined`: observable will immediately complete + * - returns observable: observerable will mirror the returned value + * - otherwise: observable will emit the value and then complete + * + * @param {Function} + * @returns {Observable} + */ +export function $fromCallback(factory: () => T | Observable): Observable { + return new Observable(observer => { + const result = factory(); + + if (result === undefined) { + observer.complete(); + } else if (isObservable(result)) { + return result.subscribe(observer); + } else { + observer.next(result); + observer.complete(); + } + }); +} diff --git a/src/core/lib/kbn_observable/factories/from_promise.ts b/src/core/lib/kbn_observable/factories/from_promise.ts new file mode 100644 index 00000000000000..434953f60dffbc --- /dev/null +++ b/src/core/lib/kbn_observable/factories/from_promise.ts @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from '../observable'; + +/** + * Create an observable that mirrors a promise. If the promise resolves the + * observable will emit the resolved value and then complete. If it rejects then + * the observable will error. + * + * @param {Promise} + * @return {Observable} + */ +export function $fromPromise(promise: Promise): Observable { + return new Observable(observer => { + promise.then( + value => { + observer.next(value); + observer.complete(); + }, + error => { + observer.error(error); + } + ); + }); +} diff --git a/src/core/lib/kbn_observable/factories/index.ts b/src/core/lib/kbn_observable/factories/index.ts new file mode 100644 index 00000000000000..9754a53226ffe9 --- /dev/null +++ b/src/core/lib/kbn_observable/factories/index.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { $from } from './from'; +export { $combineLatest } from './combine_latest'; +export { $concat } from './concat'; +export { $fromCallback } from './from_callback'; +export { $bindNodeCallback } from './bind_node_callback'; +export { $fromPromise } from './from_promise'; +export { $of } from './of'; +export { $error } from './error'; diff --git a/src/core/lib/kbn_observable/factories/of.ts b/src/core/lib/kbn_observable/factories/of.ts new file mode 100644 index 00000000000000..667c1df052ee6d --- /dev/null +++ b/src/core/lib/kbn_observable/factories/of.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from '../observable'; + +/** + * Alias for `Observable.of` + */ +export function $of(...items: T[]): Observable { + return Observable.of(...items); +} diff --git a/src/core/lib/kbn_observable/index.ts b/src/core/lib/kbn_observable/index.ts new file mode 100644 index 00000000000000..a58eab789881fa --- /dev/null +++ b/src/core/lib/kbn_observable/index.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { k$ } from './k'; + +export * from './observable'; +export { Subject } from './subject'; +export { BehaviorSubject } from './behavior_subject'; + +export * from './operators'; +export * from './factories'; diff --git a/src/core/lib/kbn_observable/interfaces.ts b/src/core/lib/kbn_observable/interfaces.ts new file mode 100644 index 00000000000000..4d57715beb61cf --- /dev/null +++ b/src/core/lib/kbn_observable/interfaces.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from './observable'; + +export type UnaryFunction = (source: T) => R; + +export type OperatorFunction = UnaryFunction, Observable>; + +export type MonoTypeOperatorFunction = OperatorFunction; diff --git a/src/core/lib/kbn_observable/k.ts b/src/core/lib/kbn_observable/k.ts new file mode 100644 index 00000000000000..b1f2ed690ff3ea --- /dev/null +++ b/src/core/lib/kbn_observable/k.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { $from } from './factories'; +import { UnaryFunction } from './interfaces'; +import { pipeFromArray } from './lib'; +import { Observable, ObservableInput } from './observable'; + +export function k$(source: ObservableInput) { + function kOperations(op1: UnaryFunction, A>): A; + function kOperations(op1: UnaryFunction, A>, op2: UnaryFunction): B; + function kOperations( + op1: UnaryFunction, A>, + op2: UnaryFunction, + op3: UnaryFunction + ): C; + function kOperations( + op1: UnaryFunction, A>, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction + ): D; + function kOperations( + op1: UnaryFunction, A>, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction, + op5: UnaryFunction + ): E; + function kOperations( + op1: UnaryFunction, A>, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction, + op5: UnaryFunction, + op6: UnaryFunction + ): F; + function kOperations( + op1: UnaryFunction, A>, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction, + op5: UnaryFunction, + op6: UnaryFunction, + op7: UnaryFunction + ): G; + function kOperations( + op1: UnaryFunction, A>, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction, + op5: UnaryFunction, + op6: UnaryFunction, + op7: UnaryFunction, + op8: UnaryFunction + ): H; + function kOperations( + op1: UnaryFunction, A>, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction, + op5: UnaryFunction, + op6: UnaryFunction, + op7: UnaryFunction, + op8: UnaryFunction, + op9: UnaryFunction + ): I; + + function kOperations(...operations: Array, R>>) { + return pipeFromArray(operations)($from(source)); + } + + return kOperations; +} diff --git a/src/server/http/setup_base_path_rewrite.js b/src/core/lib/kbn_observable/lib/collect.ts similarity index 54% rename from src/server/http/setup_base_path_rewrite.js rename to src/core/lib/kbn_observable/lib/collect.ts index a9f644f1972038..b9c40ed404a821 100644 --- a/src/server/http/setup_base_path_rewrite.js +++ b/src/core/lib/kbn_observable/lib/collect.ts @@ -17,33 +17,29 @@ * under the License. */ -import Boom from 'boom'; +import { Observable } from '../observable'; -import { modifyUrl } from '../../utils'; - -export function setupBasePathRewrite(server, config) { - const basePath = config.get('server.basePath'); - const rewriteBasePath = config.get('server.rewriteBasePath'); - - if (!basePath || !rewriteBasePath) { - return; - } +/** + * Test helper that collects all actions, and returns an array with all + * `next`-ed values, plus any `error` received or a `C` if `complete` is + * triggered. + */ +export function collect(source: Observable) { + return new Promise((resolve, reject) => { + const values: any[] = []; - server.ext('onRequest', (request, reply) => { - const newUrl = modifyUrl(request.url.href, parsed => { - if (parsed.pathname.startsWith(basePath)) { - parsed.pathname = parsed.pathname.replace(basePath, '') || '/'; - } else { - return {}; - } + source.subscribe({ + next(x) { + values.push(x); + }, + error(err) { + values.push(err); + resolve(values); + }, + complete() { + values.push('C'); + resolve(values); + }, }); - - if (!newUrl) { - reply(Boom.notFound()); - return; - } - - request.setUrl(newUrl); - reply.continue(); }); } diff --git a/src/core/lib/kbn_observable/lib/index.ts b/src/core/lib/kbn_observable/lib/index.ts new file mode 100644 index 00000000000000..644c86d1bce713 --- /dev/null +++ b/src/core/lib/kbn_observable/lib/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { pipe, pipeFromArray } from './pipe'; diff --git a/src/core/lib/kbn_observable/lib/is_observable.ts b/src/core/lib/kbn_observable/lib/is_observable.ts new file mode 100644 index 00000000000000..ef1999e840534e --- /dev/null +++ b/src/core/lib/kbn_observable/lib/is_observable.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from '../observable'; + +export function isObservable(x: any): x is Observable { + return x !== null && typeof x === 'object' && x[Symbol.observable] !== undefined; +} diff --git a/src/core/lib/kbn_observable/lib/pipe.ts b/src/core/lib/kbn_observable/lib/pipe.ts new file mode 100644 index 00000000000000..4727ed406b695c --- /dev/null +++ b/src/core/lib/kbn_observable/lib/pipe.ts @@ -0,0 +1,106 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UnaryFunction } from '../interfaces'; + +export function pipe(): UnaryFunction; +export function pipe(op1: UnaryFunction): UnaryFunction; +export function pipe( + op1: UnaryFunction, + op2: UnaryFunction +): UnaryFunction; +export function pipe( + op1: UnaryFunction, + op2: UnaryFunction, + op3: UnaryFunction +): UnaryFunction; +export function pipe( + op1: UnaryFunction, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction +): UnaryFunction; +export function pipe( + op1: UnaryFunction, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction, + op5: UnaryFunction +): UnaryFunction; +export function pipe( + op1: UnaryFunction, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction, + op5: UnaryFunction, + op6: UnaryFunction +): UnaryFunction; +export function pipe( + op1: UnaryFunction, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction, + op5: UnaryFunction, + op6: UnaryFunction, + op7: UnaryFunction +): UnaryFunction; +export function pipe( + op1: UnaryFunction, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction, + op5: UnaryFunction, + op6: UnaryFunction, + op7: UnaryFunction, + op8: UnaryFunction +): UnaryFunction; +export function pipe( + op1: UnaryFunction, + op2: UnaryFunction, + op3: UnaryFunction, + op4: UnaryFunction, + op5: UnaryFunction, + op6: UnaryFunction, + op7: UnaryFunction, + op8: UnaryFunction, + op9: UnaryFunction +): UnaryFunction; + +export function pipe(...fns: Array>): UnaryFunction { + return pipeFromArray(fns); +} + +const noop: () => any = () => { + // noop +}; + +/* @internal */ +export function pipeFromArray(fns: Array>): UnaryFunction { + if (fns.length === 0) { + return noop as UnaryFunction; + } + + if (fns.length === 1) { + return fns[0]; + } + + return function piped(input: T): R { + return fns.reduce((prev: any, fn) => fn(prev), input); + }; +} diff --git a/src/core/lib/kbn_observable/observable.ts b/src/core/lib/kbn_observable/observable.ts new file mode 100644 index 00000000000000..59f48f3ce25775 --- /dev/null +++ b/src/core/lib/kbn_observable/observable.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + Observable, + ObservableInput, + Subscription, + Subscribable, + SubscriptionObserver, + Observer, + PartialObserver, +} from '../kbn_internal_native_observable'; diff --git a/src/core/lib/kbn_observable/operators/__tests__/__snapshots__/first.test.ts.snap b/src/core/lib/kbn_observable/operators/__tests__/__snapshots__/first.test.ts.snap new file mode 100644 index 00000000000000..048618a8fd6139 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/__snapshots__/first.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`returns error if completing without receiving any value 1`] = ` +Array [ + [Error: EmptyError: first() requires source stream to emit at least one value.], +] +`; diff --git a/src/core/lib/kbn_observable/operators/__tests__/__snapshots__/last.test.ts.snap b/src/core/lib/kbn_observable/operators/__tests__/__snapshots__/last.test.ts.snap new file mode 100644 index 00000000000000..cb47283d57b7f8 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/__snapshots__/last.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`returns error if completing without receiving any value 1`] = ` +Array [ + Array [ + [Error: EmptyError: last() requires source stream to emit at least one value.], + ], +] +`; diff --git a/src/core/lib/kbn_observable/operators/__tests__/__snapshots__/to_promise.test.ts.snap b/src/core/lib/kbn_observable/operators/__tests__/__snapshots__/to_promise.test.ts.snap new file mode 100644 index 00000000000000..e6331a1914b2bf --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/__snapshots__/to_promise.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`rejects if error received 1`] = ` +Array [ + Array [ + [Error: fail], + ], +] +`; diff --git a/src/core/lib/kbn_observable/operators/__tests__/filter.test.ts b/src/core/lib/kbn_observable/operators/__tests__/filter.test.ts new file mode 100644 index 00000000000000..49b5171bb9683a --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/filter.test.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { filter } from '../'; +import { $from } from '../../factories'; +import { k$ } from '../../k'; +import { collect } from '../../lib/collect'; + +const number$ = $from([1, 2, 3]); + +test('returns the filtered values', async () => { + const filter$ = k$(number$)(filter(n => n > 1)); + + const res = collect(filter$); + expect(await res).toEqual([2, 3, 'C']); +}); + +test('sends the index as arg 2', async () => { + const filter$ = k$(number$)(filter((n, i) => i > 1)); + + const res = collect(filter$); + expect(await res).toEqual([3, 'C']); +}); diff --git a/src/core/lib/kbn_observable/operators/__tests__/first.test.ts b/src/core/lib/kbn_observable/operators/__tests__/first.test.ts new file mode 100644 index 00000000000000..a731f344368d0d --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/first.test.ts @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { first } from '../'; +import { k$ } from '../../k'; +import { collect } from '../../lib/collect'; +import { Subject } from '../../subject'; + +test('returns the first value, then completes', async () => { + const values$ = new Subject(); + + const observable = k$(values$)(first()); + const res = collect(observable); + + values$.next('foo'); + values$.next('bar'); + + expect(await res).toEqual(['foo', 'C']); +}); + +test('handles source completing after receiving value', async () => { + const values$ = new Subject(); + + const observable = k$(values$)(first()); + const res = collect(observable); + + values$.next('foo'); + values$.next('bar'); + values$.complete(); + + expect(await res).toEqual(['foo', 'C']); +}); + +test('returns error if completing without receiving any value', async () => { + const values$ = new Subject(); + + const observable = k$(values$)(first()); + const res = collect(observable); + + values$.complete(); + + expect(await res).toMatchSnapshot(); +}); diff --git a/src/core/lib/kbn_observable/operators/__tests__/last.test.ts b/src/core/lib/kbn_observable/operators/__tests__/last.test.ts new file mode 100644 index 00000000000000..765610cf207e76 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/last.test.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { last } from '../'; +import { k$ } from '../../k'; +import { Subject } from '../../subject'; + +test('returns the last value', async () => { + const values$ = new Subject(); + + const next = jest.fn(); + const error = jest.fn(); + const complete = jest.fn(); + + k$(values$)(last()).subscribe(next, error, complete); + + values$.next('foo'); + expect(next).not.toHaveBeenCalled(); + + values$.next('bar'); + expect(next).not.toHaveBeenCalled(); + + values$.complete(); + + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith('bar'); + expect(error).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalledTimes(1); +}); + +test('returns error if completing without receiving any value', async () => { + const values$ = new Subject(); + + const error = jest.fn(); + + k$(values$)(last()).subscribe({ + error, + }); + + values$.complete(); + + expect(error).toHaveBeenCalledTimes(1); + expect(error.mock.calls).toMatchSnapshot(); +}); diff --git a/src/core/lib/kbn_observable/operators/__tests__/map.test.ts b/src/core/lib/kbn_observable/operators/__tests__/map.test.ts new file mode 100644 index 00000000000000..6a2791a7de1981 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/map.test.ts @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { map, toArray, toPromise } from '../'; +import { $from } from '../../factories'; +import { k$ } from '../../k'; +import { Observable } from '../../observable'; + +const number$ = $from([1, 2, 3]); +const collect = (source: Observable) => k$(source)(toArray(), toPromise()); + +test('returns the modified value', async () => { + const numbers = await collect(k$(number$)(map(n => n * 1000))); + + expect(numbers).toEqual([1000, 2000, 3000]); +}); + +test('sends the index as arg 2', async () => { + const numbers = await collect(k$(number$)(map((n, i) => i))); + + expect(numbers).toEqual([0, 1, 2]); +}); diff --git a/src/core/lib/kbn_observable/operators/__tests__/merge_map.test.ts b/src/core/lib/kbn_observable/operators/__tests__/merge_map.test.ts new file mode 100644 index 00000000000000..f28a89bfb02778 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/merge_map.test.ts @@ -0,0 +1,145 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { map, mergeMap } from '../'; +import { $error, $of } from '../../factories'; +import { k$ } from '../../k'; +import { collect } from '../../lib/collect'; +import { Observable } from '../../observable'; +import { Subject } from '../../subject'; + +const tickMs = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +test('should mergeMap many outer values to many inner values', async () => { + const inner$ = new Subject(); + + const outer$ = Observable.from([1, 2, 3, 4]); + const project = (value: number) => k$(inner$)(map(x => `${value}-${x}`)); + + const observable = k$(outer$)(mergeMap(project)); + const res = collect(observable); + + await tickMs(10); + inner$.next('a'); + + await tickMs(10); + inner$.next('b'); + + await tickMs(10); + inner$.next('c'); + + inner$.complete(); + + expect(await res).toEqual([ + '1-a', + '2-a', + '3-a', + '4-a', + '1-b', + '2-b', + '3-b', + '4-b', + '1-c', + '2-c', + '3-c', + '4-c', + 'C', + ]); +}); + +test('should mergeMap many outer values to many inner values, early complete', async () => { + const outer$ = new Subject(); + const inner$ = new Subject(); + + const project = (value: number) => k$(inner$)(map(x => `${value}-${x}`)); + + const observable = k$(outer$)(mergeMap(project)); + const res = collect(observable); + + outer$.next(1); + outer$.next(2); + outer$.complete(); + + // This shouldn't end up in the results because `outer$` has completed. + outer$.next(3); + + await tickMs(5); + inner$.next('a'); + + await tickMs(5); + inner$.next('b'); + + await tickMs(5); + inner$.next('c'); + + inner$.complete(); + + expect(await res).toEqual(['1-a', '2-a', '1-b', '2-b', '1-c', '2-c', 'C']); +}); + +test('should mergeMap many outer to many inner, and inner throws', async () => { + const source = Observable.from([1, 2, 3, 4]); + const error = new Error('fail'); + + const project = (value: number, index: number) => (index > 1 ? $error(error) : $of(value)); + + const observable = k$(source)(mergeMap(project)); + const res = collect(observable); + + expect(await res).toEqual([1, 2, error]); +}); + +test('should mergeMap many outer to many inner, and outer throws', async () => { + const outer$ = new Subject(); + const inner$ = new Subject(); + + const project = (value: number) => k$(inner$)(map(x => `${value}-${x}`)); + + const observable = k$(outer$)(mergeMap(project)); + const res = collect(observable); + + outer$.next(1); + outer$.next(2); + + const error = new Error('outer fails'); + + await tickMs(5); + inner$.next('a'); + + await tickMs(5); + inner$.next('b'); + + outer$.error(error); + // This shouldn't end up in the results because `outer$` has failed + outer$.next(3); + + await tickMs(5); + inner$.next('c'); + + expect(await res).toEqual(['1-a', '2-a', '1-b', '2-b', error]); +}); + +test('should mergeMap many outer to an array for each value', async () => { + const source = Observable.from([1, 2, 3]); + + const observable = k$(source)(mergeMap(() => $of('a', 'b', 'c'))); + const res = collect(observable); + + expect(await res).toEqual(['a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'C']); +}); diff --git a/src/core/lib/kbn_observable/operators/__tests__/reduce.test.ts b/src/core/lib/kbn_observable/operators/__tests__/reduce.test.ts new file mode 100644 index 00000000000000..e56e31f4d9c6cb --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/reduce.test.ts @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { reduce } from '../'; +import { k$ } from '../../k'; +import { collect } from '../../lib/collect'; +import { Subject } from '../../subject'; + +test('completes when source completes', async () => { + const subject = new Subject(); + + const observable = k$(subject)( + reduce((acc, val) => { + return acc + val; + }, 'foo') + ); + const res = collect(observable); + + subject.next('bar'); + subject.next('baz'); + subject.complete(); + + expect(await res).toEqual(['foobarbaz', 'C']); +}); + +test('injects index', async () => { + const subject = new Subject(); + + const observable = k$(subject)( + reduce((acc, val, index) => { + return acc + index; + }, 'foo') + ); + const res = collect(observable); + + subject.next('bar'); + subject.next('baz'); + subject.complete(); + + expect(await res).toEqual(['foo01', 'C']); +}); + +test('completes with initial value if no values received', async () => { + const subject = new Subject(); + + const observable = k$(subject)( + reduce((acc, val, index) => { + return acc + val; + }, 'foo') + ); + const res = collect(observable); + subject.complete(); + + expect(await res).toEqual(['foo', 'C']); +}); diff --git a/src/core/lib/kbn_observable/operators/__tests__/scan.test.ts b/src/core/lib/kbn_observable/operators/__tests__/scan.test.ts new file mode 100644 index 00000000000000..42e739de197f59 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/scan.test.ts @@ -0,0 +1,72 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { scan } from '../'; +import { k$ } from '../../k'; +import { collect } from '../../lib/collect'; +import { Subject } from '../../subject'; + +test('completes when source completes', async () => { + const subject = new Subject(); + + const observable = k$(subject)( + scan((acc, val) => { + return acc + val; + }, 'foo') + ); + const res = collect(observable); + + subject.next('bar'); + subject.next('baz'); + subject.complete(); + + expect(await res).toEqual(['foobar', 'foobarbaz', 'C']); +}); + +test('injects index', async () => { + const subject = new Subject(); + + const observable = k$(subject)( + scan((acc, val, index) => { + return acc + index; + }, 'foo') + ); + const res = collect(observable); + + subject.next('bar'); + subject.next('baz'); + subject.complete(); + + expect(await res).toEqual(['foo0', 'foo01', 'C']); +}); + +test('completes if no values received', async () => { + const subject = new Subject(); + + const observable = k$(subject)( + scan((acc, val, index) => { + return acc + val; + }, 'foo') + ); + const res = collect(observable); + + subject.complete(); + + expect(await res).toEqual(['C']); +}); diff --git a/src/core/lib/kbn_observable/operators/__tests__/skip_repeats.test.ts b/src/core/lib/kbn_observable/operators/__tests__/skip_repeats.test.ts new file mode 100644 index 00000000000000..47059eeb3dc67f --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/skip_repeats.test.ts @@ -0,0 +1,208 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { skipRepeats } from '../'; +import { $of } from '../../factories'; +import { k$ } from '../../k'; +import { collect } from '../../lib/collect'; +import { Observable } from '../../observable'; +import { Subject } from '../../subject'; + +test('should distinguish between values', async () => { + const values$ = new Subject(); + + const observable = k$(values$)(skipRepeats()); + const res = collect(observable); + + values$.next('a'); + values$.next('a'); + values$.next('a'); + values$.next('b'); + values$.next('b'); + values$.next('a'); + values$.next('a'); + values$.complete(); + + expect(await res).toEqual(['a', 'b', 'a', 'C']); +}); + +test('should distinguish between values and does not complete', () => { + const values$ = new Subject(); + + const actual: any[] = []; + k$(values$)(skipRepeats()).subscribe({ + next(v) { + actual.push(v); + }, + }); + + values$.next('a'); + values$.next('a'); + values$.next('a'); + values$.next('b'); + values$.next('b'); + values$.next('a'); + values$.next('a'); + + expect(actual).toEqual(['a', 'b', 'a']); +}); + +test('should complete if source is empty', done => { + const values$ = $of(); + + k$(values$)(skipRepeats()).subscribe({ + complete: done, + }); +}); + +test('should emit if source emits single element only', () => { + const values$ = new Subject(); + + const actual: any[] = []; + k$(values$)(skipRepeats()).subscribe({ + next(x) { + actual.push(x); + }, + }); + + values$.next('a'); + + expect(actual).toEqual(['a']); +}); + +test('should emit if source is scalar', () => { + const values$ = $of('a'); + + const actual: any[] = []; + k$(values$)(skipRepeats()).subscribe({ + next(v) { + actual.push(v); + }, + }); + + expect(actual).toEqual(['a']); +}); + +test('should raise error if source raises error', async () => { + const values$ = new Subject(); + + const observable = k$(values$)(skipRepeats()); + const res = collect(observable); + + values$.next('a'); + values$.next('a'); + + const thrownError = new Error('nope'); + values$.error(thrownError); + + expect(await res).toEqual(['a', thrownError]); +}); + +test('should raise error if source throws', () => { + const thrownError = new Error('fail'); + + const obs = new Observable(observer => { + observer.error(thrownError); + }); + + const error = jest.fn(); + k$(obs)(skipRepeats()).subscribe({ + error, + }); + + expect(error).toHaveBeenCalledWith(thrownError); +}); + +test('should allow unsubscribing early and explicitly', () => { + const values$ = new Subject(); + + const actual: any[] = []; + const sub = k$(values$)(skipRepeats()).subscribe({ + next(v) { + actual.push(v); + }, + }); + + values$.next('a'); + values$.next('a'); + values$.next('b'); + + sub.unsubscribe(); + + values$.next('c'); + values$.next('d'); + + expect(actual).toEqual(['a', 'b']); +}); + +test('should emit once if comparator returns true always regardless of source emits', () => { + const values$ = new Subject(); + + const actual: any[] = []; + k$(values$)(skipRepeats(() => true)).subscribe({ + next(v) { + actual.push(v); + }, + }); + + values$.next('a'); + values$.next('a'); + values$.next('b'); + values$.next('c'); + + expect(actual).toEqual(['a']); +}); + +test('should emit all if comparator returns false always regardless of source emits', () => { + const values$ = new Subject(); + + const actual: any[] = []; + k$(values$)(skipRepeats(() => false)).subscribe({ + next(v) { + actual.push(v); + }, + }); + + values$.next('a'); + values$.next('a'); + values$.next('a'); + values$.next('a'); + + expect(actual).toEqual(['a', 'a', 'a', 'a']); +}); + +test('should distinguish values by comparator', () => { + const values$ = new Subject(); + + const comparator = (x: number, y: number) => y % 2 === 0; + + const actual: any[] = []; + k$(values$)(skipRepeats(comparator)).subscribe({ + next(v) { + actual.push(v); + }, + }); + + values$.next(1); + values$.next(2); + values$.next(3); + values$.next(4); + + expect(actual).toEqual([1, 3]); +}); diff --git a/src/core/lib/kbn_observable/operators/__tests__/switch_map.test.ts b/src/core/lib/kbn_observable/operators/__tests__/switch_map.test.ts new file mode 100644 index 00000000000000..a929707908ae86 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/switch_map.test.ts @@ -0,0 +1,302 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { switchMap } from '../'; +import { $of } from '../../factories'; +import { k$ } from '../../k'; +import { collect } from '../../lib/collect'; +import { Observable } from '../../observable'; +import { Subject } from '../../subject'; + +const number$ = $of(1, 2, 3); + +test('returns the modified value', async () => { + const expected = ['a1', 'b1', 'c1', 'a2', 'b2', 'c2', 'a3', 'b3', 'c3', 'C']; + + const observable = k$(number$)(switchMap(x => $of('a' + x, 'b' + x, 'c' + x))); + const res = collect(observable); + + expect(await res).toEqual(expected); +}); + +test('injects index to map', async () => { + const observable = k$(number$)(switchMap((x, i) => $of(i))); + const res = collect(observable); + + expect(await res).toEqual([0, 1, 2, 'C']); +}); + +test('should unsubscribe inner observable when source observable emits new value', async () => { + const unsubbed: string[] = []; + const subject = new Subject(); + + k$(subject)( + switchMap( + x => + new Observable(observer => { + return () => { + unsubbed.push(x); + }; + }) + ) + ).subscribe(); + + subject.next('a'); + expect(unsubbed).toEqual([]); + + subject.next('b'); + expect(unsubbed).toEqual(['a']); + + subject.next('c'); + expect(unsubbed).toEqual(['a', 'b']); + + subject.complete(); + expect(unsubbed).toEqual(['a', 'b', 'c']); +}); + +test('should unsubscribe inner observable when source observable errors', async () => { + const unsubbed: string[] = []; + const subject = new Subject(); + + k$(subject)( + switchMap( + x => + new Observable(observer => { + return () => { + unsubbed.push(x); + }; + }) + ) + ).subscribe(); + + subject.next('a'); + subject.error(new Error('fail')); + + expect(unsubbed).toEqual(['a']); +}); + +test('should unsubscribe inner observables if inner observer completes', async () => { + const unsubbed: string[] = []; + const subject = new Subject(); + + k$(subject)( + switchMap( + x => + new Observable(observer => { + observer.complete(); + return () => { + unsubbed.push(x); + }; + }) + ) + ).subscribe(); + + subject.next('a'); + expect(unsubbed).toEqual(['a']); + + subject.next('b'); + expect(unsubbed).toEqual(['a', 'b']); + + subject.complete(); + expect(unsubbed).toEqual(['a', 'b']); +}); + +test('should unsubscribe inner observables if inner observer errors', async () => { + const unsubbed: string[] = []; + const subject = new Subject(); + + const error = jest.fn(); + const thrownError = new Error('fail'); + + k$(subject)( + switchMap( + x => + new Observable(observer => { + observer.error(thrownError); + return () => { + unsubbed.push(x); + }; + }) + ) + ).subscribe({ + error, + }); + + subject.next('a'); + expect(unsubbed).toEqual(['a']); + + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith(thrownError); +}); + +test('should switch inner observables', () => { + const outer$ = new Subject<'x' | 'y'>(); + const inner$ = { + x: new Subject(), + y: new Subject(), + }; + + const actual: any[] = []; + + k$(outer$)(switchMap(x => inner$[x])).subscribe({ + next(val) { + actual.push(val); + }, + }); + + outer$.next('x'); + inner$.x.next('foo'); + inner$.x.next('bar'); + + outer$.next('y'); + inner$.x.next('baz'); + inner$.y.next('quux'); + + outer$.complete(); + + expect(actual).toEqual(['foo', 'bar', 'quux']); +}); + +test('should switch inner empty and empty', () => { + const outer$ = new Subject<'x' | 'y'>(); + const inner$ = { + x: new Subject(), + y: new Subject(), + }; + + const next = jest.fn(); + + k$(outer$)(switchMap(x => inner$[x])).subscribe(next); + + outer$.next('x'); + inner$.x.complete(); + + outer$.next('y'); + inner$.y.complete(); + + outer$.complete(); + + expect(next).not.toHaveBeenCalled(); +}); + +test('should switch inner never and throw', async () => { + const error = new Error('sad'); + + const outer$ = new Subject<'x' | 'y'>(); + const inner$ = { + x: new Subject(), + y: new Subject(), + }; + + inner$.y.error(error); + + const observable = k$(outer$)(switchMap(x => inner$[x])); + const res = collect(observable); + + outer$.next('x'); + outer$.next('y'); + outer$.complete(); + + expect(await res).toEqual([error]); +}); + +test('should handle outer throw', async () => { + const error = new Error('foo'); + const outer$ = new Observable(observer => { + throw error; + }); + + const observable = k$(outer$)(switchMap(x => $of(x))); + const res = collect(observable); + + expect(await res).toEqual([error]); +}); + +test('should handle outer error', async () => { + const outer$ = new Subject<'x'>(); + const inner$ = { + x: new Subject(), + }; + + const observable = k$(outer$)(switchMap(x => inner$[x])); + const res = collect(observable); + + outer$.next('x'); + + inner$.x.next('a'); + inner$.x.next('b'); + inner$.x.next('c'); + + const error = new Error('foo'); + outer$.error(error); + + inner$.x.next('d'); + inner$.x.next('e'); + + expect(await res).toEqual(['a', 'b', 'c', error]); +}); + +test('should raise error when projection throws', async () => { + const outer$ = new Subject(); + const error = new Error('foo'); + + const observable = k$(outer$)( + switchMap(x => { + throw error; + }) + ); + const res = collect(observable); + + outer$.next('x'); + + expect(await res).toEqual([error]); +}); + +test('should switch inner cold observables, outer is unsubscribed early', () => { + const outer$ = new Subject<'x' | 'y'>(); + const inner$ = { + x: new Subject(), + y: new Subject(), + }; + + const actual: any[] = []; + const sub = k$(outer$)(switchMap(x => inner$[x])).subscribe({ + next(val) { + actual.push(val); + }, + }); + + outer$.next('x'); + inner$.x.next('foo'); + inner$.x.next('bar'); + + outer$.next('y'); + inner$.y.next('baz'); + inner$.y.next('quux'); + + sub.unsubscribe(); + + inner$.x.next('post x'); + inner$.x.complete(); + + inner$.y.next('post y'); + inner$.y.complete(); + + expect(actual).toEqual(['foo', 'bar', 'baz', 'quux']); +}); diff --git a/src/core/lib/kbn_observable/operators/__tests__/to_promise.test.ts b/src/core/lib/kbn_observable/operators/__tests__/to_promise.test.ts new file mode 100644 index 00000000000000..5a07d49537c6c7 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/__tests__/to_promise.test.ts @@ -0,0 +1,85 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { toPromise } from '../'; +import { k$ } from '../../k'; +import { Subject } from '../../subject'; + +// Promises are always async, so we add a simple helper that we can `await` to +// make sure they have completed. +const tick = () => Promise.resolve(); + +test('returns the last value', async () => { + const values$ = new Subject(); + + const resolved = jest.fn(); + const rejected = jest.fn(); + + k$(values$)(toPromise()).then(resolved, rejected); + + values$.next('foo'); + await tick(); + + expect(resolved).not.toHaveBeenCalled(); + expect(rejected).not.toHaveBeenCalled(); + + values$.next('bar'); + await tick(); + + expect(resolved).not.toHaveBeenCalled(); + expect(rejected).not.toHaveBeenCalled(); + + values$.complete(); + await tick(); + + expect(resolved).toHaveBeenCalledTimes(1); + expect(resolved).toHaveBeenCalledWith('bar'); + expect(rejected).not.toHaveBeenCalled(); +}); + +test('resolves even if no values received', async () => { + const values$ = new Subject(); + + const resolved = jest.fn(); + const rejected = jest.fn(); + + k$(values$)(toPromise()).then(resolved, rejected); + + values$.complete(); + await tick(); + + expect(rejected).not.toHaveBeenCalled(); + expect(resolved).toHaveBeenCalledTimes(1); +}); + +test('rejects if error received', async () => { + const values$ = new Subject(); + + const resolved = jest.fn(); + const rejected = jest.fn(); + + k$(values$)(toPromise()).then(resolved, rejected); + + values$.error(new Error('fail')); + await tick(); + + expect(resolved).not.toHaveBeenCalled(); + expect(rejected).toHaveBeenCalledTimes(1); + expect(rejected.mock.calls).toMatchSnapshot(); +}); diff --git a/src/core/lib/kbn_observable/operators/filter.ts b/src/core/lib/kbn_observable/operators/filter.ts new file mode 100644 index 00000000000000..a6eb48cc89e956 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/filter.ts @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { MonoTypeOperatorFunction } from '../interfaces'; +import { Observable } from '../observable'; + +/** + * Filter items emitted by the source Observable by only emitting those that + * satisfy a specified predicate. + * + * @param predicate A function that evaluates each value emitted by the source + * Observable. If it returns `true`, the value is emitted, if `false` the value + * is not passed to the output Observable. The `index` parameter is the number + * `i` for the i-th source emission that has happened since the subscription, + * starting from the number `0`. + * @return An Observable of values from the source that were allowed by the + * `predicate` function. + */ +export function filter( + predicate: (value: T, index: number) => boolean +): MonoTypeOperatorFunction { + return function filterOperation(source) { + return new Observable(observer => { + let i = 0; + + return source.subscribe({ + next(value) { + let result = false; + try { + result = predicate(value, i++); + } catch (e) { + observer.error(e); + return; + } + if (result) { + observer.next(value); + } + }, + error(error) { + observer.error(error); + }, + complete() { + observer.complete(); + }, + }); + }); + }; +} diff --git a/src/core/lib/kbn_observable/operators/first.ts b/src/core/lib/kbn_observable/operators/first.ts new file mode 100644 index 00000000000000..eb3c0d7b5cfdb1 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/first.ts @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EmptyError } from '../errors'; +import { MonoTypeOperatorFunction } from '../interfaces'; +import { Observable } from '../observable'; + +/** + * Emits the first value emitted by the source Observable, then immediately + * completes. + * + * @throws {EmptyError} Delivers an EmptyError to the Observer's `error` + * callback if the Observable completes before any `next` notification was sent. + * + * @returns An Observable of the first item received. + */ +export function first(): MonoTypeOperatorFunction { + return function firstOperation(source) { + return new Observable(observer => { + return source.subscribe({ + next(value) { + observer.next(value); + observer.complete(); + }, + error(error) { + observer.error(error); + }, + complete() { + // The only time we end up here, is if we never received any values. + observer.error(new EmptyError('first()')); + }, + }); + }); + }; +} diff --git a/src/core/lib/kbn_observable/operators/if_empty.ts b/src/core/lib/kbn_observable/operators/if_empty.ts new file mode 100644 index 00000000000000..857de65caa6d57 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/if_empty.ts @@ -0,0 +1,62 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { $fromCallback } from '../factories'; +import { MonoTypeOperatorFunction } from '../interfaces'; +import { Observable } from '../observable'; + +/** + * Modifies a stream so that when the source completes without emitting any + * values a new observable is created via `factory()` (see `$fromCallback`) that + * will be mirrored to completion. + * + * @param factory + * @return + */ +export function ifEmpty(factory: () => T): MonoTypeOperatorFunction { + return function ifEmptyOperation(source) { + return new Observable(observer => { + let hasReceivedValue = false; + + const subs = [ + source.subscribe({ + next(value) { + hasReceivedValue = true; + observer.next(value); + }, + error(error) { + observer.error(error); + }, + complete() { + if (hasReceivedValue) { + observer.complete(); + } else { + subs.push($fromCallback(factory).subscribe(observer)); + } + }, + }), + ]; + + return () => { + subs.forEach(sub => sub.unsubscribe()); + subs.length = 0; + }; + }); + }; +} diff --git a/src/core/lib/kbn_observable/operators/index.ts b/src/core/lib/kbn_observable/operators/index.ts new file mode 100644 index 00000000000000..200bf256fa9e53 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/index.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ifEmpty } from './if_empty'; +export { last } from './last'; +export { first } from './first'; +export { map } from './map'; +export { filter } from './filter'; +export { reduce } from './reduce'; +export { scan } from './scan'; +export { toArray } from './to_array'; +export { switchMap } from './switch_map'; +export { mergeMap } from './merge_map'; +export { skipRepeats } from './skip_repeats'; +export { toPromise } from './to_promise'; diff --git a/src/core/lib/kbn_observable/operators/last.ts b/src/core/lib/kbn_observable/operators/last.ts new file mode 100644 index 00000000000000..a32d552dd4d8de --- /dev/null +++ b/src/core/lib/kbn_observable/operators/last.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EmptyError } from '../errors'; +import { MonoTypeOperatorFunction } from '../interfaces'; +import { Observable } from '../observable'; + +/** + * Emits the last value emitted by the source Observable, then immediately + * completes. + * + * @throws {EmptyError} Delivers an EmptyError to the Observer's `error` + * callback if the Observable completes before any `next` notification was sent. + * + * @returns An Observable of the last item received. + */ +export function last(): MonoTypeOperatorFunction { + return function lastOperation(source) { + return new Observable(observer => { + let hasReceivedValue = false; + let latest: T; + + return source.subscribe({ + next(value) { + hasReceivedValue = true; + latest = value; + }, + error(error) { + observer.error(error); + }, + complete() { + if (hasReceivedValue) { + observer.next(latest); + observer.complete(); + } else { + observer.error(new EmptyError('last()')); + } + }, + }); + }); + }; +} diff --git a/src/core/lib/kbn_observable/operators/map.ts b/src/core/lib/kbn_observable/operators/map.ts new file mode 100644 index 00000000000000..68c16542b155a8 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/map.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorFunction } from '../interfaces'; +import { Observable } from '../observable'; + +/** + * Modifies each value from the source by passing it to `fn(item, i)` and + * emitting the return value of that function instead. + * + * @param fn The function to apply to each `value` emitted by the source + * Observable. The `index` parameter is the number `i` for the i-th emission + * that has happened since the subscription, starting from the number `0`. + * @return An Observable that emits the values from the source Observable + * transformed by the given `fn` function. + */ +export function map(fn: (value: T, index: number) => R): OperatorFunction { + return function mapOperation(source) { + return new Observable(observer => { + let i = 0; + + return source.subscribe({ + next(value) { + let result: R; + try { + result = fn(value, i++); + } catch (e) { + observer.error(e); + return; + } + observer.next(result); + }, + error(error) { + observer.error(error); + }, + complete() { + observer.complete(); + }, + }); + }); + }; +} diff --git a/src/core/lib/kbn_observable/operators/merge_map.ts b/src/core/lib/kbn_observable/operators/merge_map.ts new file mode 100644 index 00000000000000..4d94d12cf4d829 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/merge_map.ts @@ -0,0 +1,118 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { $from } from '../factories'; +import { OperatorFunction } from '../interfaces'; +import { Observable, ObservableInput } from '../observable'; + +/** + * Projects each source value to an Observable which is merged in the output + * Observable. + * + * Example: + * + * ```js + * const source = Observable.from([1, 2, 3]); + * const observable = k$(source)( + * mergeMap(x => Observable.of('a', x + 1)) + * ); + * ``` + * + * Results in the following items emitted: + * - a + * - 2 + * - a + * - 3 + * - a + * - 4 + * + * As you can see it merges the returned observable and emits every value from + * that observable. You can think of it as being the same as `flatMap` on an + * array, just that you return an Observable instead of an array. + * + * For more complex use-cases where you need the source variable for each value + * in the newly created observable, an often used pattern is using `map` within + * the `mergeMap`. E.g. let's say we want to return both the current value and + * the newly created value: + * + * ```js + * mergeMap(val => + * k$(someFn(val))( + * map(newVal => ({ val, newVal }) + * ) + * ) + * ``` + * + * Here you would go from having an observable of `val`s, to having an + * observable of `{ val, newVal }` objects. + * + * @param project A function that, when applied to an item emitted by the source + * Observable, returns an Observable. + */ +export function mergeMap( + project: (value: T, index: number) => ObservableInput +): OperatorFunction { + return function mergeMapOperation(source) { + return new Observable(destination => { + let completed = false; + let active = 0; + let i = 0; + + source.subscribe({ + next(value) { + let result; + try { + result = project(value, i++); + } catch (error) { + destination.error(error); + return; + } + active++; + + $from(result).subscribe({ + next(innerValue) { + destination.next(innerValue); + }, + error(err) { + destination.error(err); + }, + complete() { + active--; + + if (active === 0 && completed) { + destination.complete(); + } + }, + }); + }, + + error(err) { + destination.error(err); + }, + + complete() { + completed = true; + if (active === 0) { + destination.complete(); + } + }, + }); + }); + }; +} diff --git a/src/core/lib/kbn_observable/operators/reduce.ts b/src/core/lib/kbn_observable/operators/reduce.ts new file mode 100644 index 00000000000000..b0b25a38a8d2c5 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/reduce.ts @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorFunction } from '../interfaces'; +import { pipe } from '../lib'; +import { ifEmpty } from './if_empty'; +import { last } from './last'; +import { scan } from './scan'; + +/** + * Applies the accumulator function to every value in the source observable and + * emits the return value when the source completes. + * + * It's like {@link scan}, but only emits when the source observable completes, + * not the current accumulation whenever the source emits a value. + * + * If no values are emitted, the `initialValue` will be emitted. + * + * @param accumulator The accumulator function called on each source value. + * @param initialValue The initial accumulation value. + * @return An Observable that emits a single value that is the result of + * accumulating the values emitted by the source Observable. + */ +export function reduce( + accumulator: (acc: R, value: T, index: number) => R, + initialValue: R +): OperatorFunction { + return function reduceOperation(source) { + return pipe(scan(accumulator, initialValue), ifEmpty(() => initialValue), last())(source); + }; +} diff --git a/src/core/lib/kbn_observable/operators/scan.ts b/src/core/lib/kbn_observable/operators/scan.ts new file mode 100644 index 00000000000000..f2005f94a04004 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/scan.ts @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorFunction } from '../interfaces'; +import { Observable } from '../observable'; + +/** + * Applies the accumulator function to every value in the source stream and + * emits the return value of each invocation. + * + * It's like {@link reduce}, but emits the current accumulation whenever the + * source emits a value instead of emitting only when completed. + * + * @param accumulator The accumulator function called on each source value. + * @param initialValue The initial accumulation value. + * @return An observable of the accumulated values. + */ +export function scan( + accumulator: (acc: R, value: T, index: number) => R, + initialValue: R +): OperatorFunction { + return function scanOperation(source) { + return new Observable(observer => { + let i = -1; + let acc = initialValue; + + return source.subscribe({ + next(value) { + i += 1; + + try { + acc = accumulator(acc, value, i); + + observer.next(acc); + } catch (error) { + observer.error(error); + } + }, + error(error) { + observer.error(error); + }, + complete() { + observer.complete(); + }, + }); + }); + }; +} diff --git a/src/core/lib/kbn_observable/operators/skip_repeats.ts b/src/core/lib/kbn_observable/operators/skip_repeats.ts new file mode 100644 index 00000000000000..18dc16f8bce296 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/skip_repeats.ts @@ -0,0 +1,68 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { MonoTypeOperatorFunction } from '../interfaces'; +import { Observable } from '../observable'; + +const isStrictlyEqual = (a: any, b: any) => a === b; + +/** + * Returns an Observable that emits all items emitted by the source Observable + * that are not equal to the previous item. + * + * @param [equals] Optional comparison function called to test if an item is + * equal to the previous item in the source. Should return `true` if equal, + * otherwise `false`. By default compares using reference equality, aka `===`. + * @return An Observable that emits items from the source Observable with + * distinct values. + */ +export function skipRepeats( + equals: (x: T, y: T) => boolean = isStrictlyEqual +): MonoTypeOperatorFunction { + return function skipRepeatsOperation(source) { + return new Observable(observer => { + let hasInitialValue = false; + let currentValue: T; + + return source.subscribe({ + next(value) { + if (!hasInitialValue) { + hasInitialValue = true; + currentValue = value; + observer.next(value); + return; + } + + const isEqual = equals(currentValue, value); + + if (!isEqual) { + observer.next(value); + currentValue = value; + } + }, + error(error) { + observer.error(error); + }, + complete() { + observer.complete(); + }, + }); + }); + }; +} diff --git a/src/core/lib/kbn_observable/operators/switch_map.ts b/src/core/lib/kbn_observable/operators/switch_map.ts new file mode 100644 index 00000000000000..87615aeb90727f --- /dev/null +++ b/src/core/lib/kbn_observable/operators/switch_map.ts @@ -0,0 +1,121 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorFunction } from '../interfaces'; +import { Observable, Subscription } from '../observable'; + +/** + * Projects each source value to an Observable which is merged in the output + * Observable, emitting values only from the most recently projected Observable. + * + * To understand how `switchMap` works, take a look at: + * https://medium.com/@w.dave.w/becoming-more-reactive-with-rxjs-flatmap-and-switchmap-ccd3fb7b67fa + * + * It's kinda like a normal `flatMap`, except it's producing observables and you + * _only_ care about the latest observable it produced. One use-case for + * `switchMap` is if need to control what happens both when you create and when + * you're done with an observable, like in the example below where we want to + * write the pid file when we receive a pid config, and delete it when we + * receive new config values (or when we stop the pid service). + * + * ```js + * switchMap(config => { + * return new Observable(() => { + * const pid = new PidFile(config); + * pid.writeFile(); + * + * // Whenever a new observable is returned, `switchMap` will unsubscribe + * // from the previous observable. That means that we can e.g. run teardown + * // logic in the unsubscribe. + * return function unsubscribe() { + * pid.deleteFile(); + * }; + * }); + * }); + * ``` + * + * Another example could be emitting a value X seconds after receiving it from + * the source observable, but cancelling if another value is received before the + * timeout, e.g. + * + * ```js + * switchMap(value => { + * return new Observable(observer => { + * const id = setTimeout(() => { + * observer.next(value); + * }, 5000); + * + * return function unsubscribe() { + * clearTimeout(id); + * }; + * }); + * }); + * ``` + */ +export function switchMap( + project: (value: T, index: number) => Observable +): OperatorFunction { + return function switchMapOperation(source) { + return new Observable(observer => { + let i = 0; + let innerSubscription: Subscription | undefined; + + return source.subscribe({ + next(value) { + let result; + try { + result = project(value, i++); + } catch (error) { + observer.error(error); + return; + } + + if (innerSubscription !== undefined) { + innerSubscription.unsubscribe(); + } + + innerSubscription = result.subscribe({ + next(innerVal) { + observer.next(innerVal); + }, + error(err) { + observer.error(err); + }, + }); + }, + error(err) { + if (innerSubscription !== undefined) { + innerSubscription.unsubscribe(); + innerSubscription = undefined; + } + + observer.error(err); + }, + complete() { + if (innerSubscription !== undefined) { + innerSubscription.unsubscribe(); + innerSubscription = undefined; + } + + observer.complete(); + }, + }); + }); + }; +} diff --git a/src/core/lib/kbn_observable/operators/to_array.ts b/src/core/lib/kbn_observable/operators/to_array.ts new file mode 100644 index 00000000000000..053bf5bc8649b5 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/to_array.ts @@ -0,0 +1,36 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorFunction } from '../interfaces'; +import { Observable } from '../observable'; +import { reduce } from './reduce'; + +function concat(source: Observable) { + return reduce((acc, item) => acc.concat([item]), [] as T[])(source); +} + +/** + * Modify a stream to produce a single array containing all of the items emitted + * by source. + */ +export function toArray(): OperatorFunction { + return function toArrayOperation(source) { + return concat(source); + }; +} diff --git a/src/core/lib/kbn_observable/operators/to_promise.ts b/src/core/lib/kbn_observable/operators/to_promise.ts new file mode 100644 index 00000000000000..f63de6a2d55df0 --- /dev/null +++ b/src/core/lib/kbn_observable/operators/to_promise.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UnaryFunction } from '../interfaces'; +import { Observable } from '../observable'; + +export function toPromise(): UnaryFunction, Promise> { + return function toPromiseOperation(source) { + return new Promise((resolve, reject) => { + let lastValue: T; + + source.subscribe({ + next(value) { + lastValue = value; + }, + error(error) { + reject(error); + }, + complete() { + resolve(lastValue); + }, + }); + }); + }; +} diff --git a/src/core/lib/kbn_observable/subject.ts b/src/core/lib/kbn_observable/subject.ts new file mode 100644 index 00000000000000..8c8d8657099615 --- /dev/null +++ b/src/core/lib/kbn_observable/subject.ts @@ -0,0 +1,114 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable, SubscriptionObserver } from './observable'; + +/** + * A Subject is a special type of Observable that allows values to be + * multicasted to many Observers. While plain Observables are unicast (each + * subscribed Observer owns an independent execution of the Observable), + * Subjects are multicast. + * + * Every Subject is an Observable. Given a Subject, you can subscribe to it in + * the same way you subscribe to any Observable, and you will start receiving + * values normally. From the perspective of the Observer, it cannot tell whether + * the Observable execution is coming from a plain unicast Observable or a + * Subject. + * + * Internally to the Subject, `subscribe` does not invoke a new execution that + * delivers values. It simply registers the given Observer in a list of + * Observers, similarly to how `addListener` usually works in other libraries + * and languages. + * + * Every Subject is an Observer. It is an object with the methods `next(v)`, + * `error(e)`, and `complete()`. To feed a new value to the Subject, just call + * `next(theValue)`, and it will be multicasted to the Observers registered to + * listen to the Subject. + * + * Learn more about Subjects: + * - http://reactivex.io/documentation/subject.html + * - http://davesexton.com/blog/post/To-Use-Subject-Or-Not-To-Use-Subject.aspx + */ +export class Subject extends Observable { + protected observers: Set> = new Set(); + protected isStopped = false; + protected thrownError?: Error; + + constructor() { + super(observer => this.registerObserver(observer)); + } + + /** + * @param value The value that will be forwarded to every observer subscribed + * to this subject. + */ + public next(value: T) { + for (const observer of this.observers) { + observer.next(value); + } + } + + /** + * @param error The error that will be forwarded to every observer subscribed + * to this subject. + */ + public error(error: Error) { + this.thrownError = error; + this.isStopped = true; + + for (const observer of this.observers) { + observer.error(error); + } + + this.observers.clear(); + } + + /** + * Completes all the subscribed observers, then clears the list of observers. + */ + public complete() { + this.isStopped = true; + + for (const observer of this.observers) { + observer.complete(); + } + + this.observers.clear(); + } + + /** + * Returns an observable, so the observer methods are hidden. + */ + public asObservable(): Observable { + return new Observable(observer => this.subscribe(observer)); + } + + protected registerObserver(observer: SubscriptionObserver) { + if (this.isStopped) { + if (this.thrownError !== undefined) { + observer.error(this.thrownError); + } else { + observer.complete(); + } + } else { + this.observers.add(observer); + return () => this.observers.delete(observer); + } + } +} diff --git a/src/core/server/README.md b/src/core/server/README.md new file mode 100644 index 00000000000000..53807c4f036b80 --- /dev/null +++ b/src/core/server/README.md @@ -0,0 +1,6 @@ +Platform Server Modules +======================= + +Http Server +----------- +TODO: explain diff --git a/src/core/server/config/__tests__/__fixtures__/config.yml b/src/core/server/config/__tests__/__fixtures__/config.yml new file mode 100644 index 00000000000000..7460b17d98f0b9 --- /dev/null +++ b/src/core/server/config/__tests__/__fixtures__/config.yml @@ -0,0 +1,3 @@ +pid: + enabled: true + file: '/var/run/kibana.pid' diff --git a/src/core/server/config/__tests__/__fixtures__/config_flat.yml b/src/core/server/config/__tests__/__fixtures__/config_flat.yml new file mode 100644 index 00000000000000..35609df67a6242 --- /dev/null +++ b/src/core/server/config/__tests__/__fixtures__/config_flat.yml @@ -0,0 +1,2 @@ +pid.enabled: true +pid.file: '/var/run/kibana.pid' diff --git a/src/core/server/config/__tests__/__mocks__/env.ts b/src/core/server/config/__tests__/__mocks__/env.ts new file mode 100644 index 00000000000000..e86cd9aab77cda --- /dev/null +++ b/src/core/server/config/__tests__/__mocks__/env.ts @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Test helpers to simplify mocking environment options. + +import { EnvOptions } from '../../env'; + +interface MockEnvOptions { + config?: string; + kbnServer?: any; + mode?: EnvOptions['mode']['name']; + packageInfo?: Partial; +} + +export function getEnvOptions({ + config, + kbnServer, + mode = 'development', + packageInfo = {}, +}: MockEnvOptions = {}): EnvOptions { + return { + config, + kbnServer, + mode: { + dev: mode === 'development', + name: mode, + prod: mode === 'production', + }, + packageInfo: { + branch: 'some-branch', + buildNum: 1, + buildSha: 'some-sha-256', + version: 'some-version', + ...packageInfo, + }, + }; +} diff --git a/src/core/server/config/__tests__/__snapshots__/config_service.test.ts.snap b/src/core/server/config/__tests__/__snapshots__/config_service.test.ts.snap new file mode 100644 index 00000000000000..f24b6d1168fd9d --- /dev/null +++ b/src/core/server/config/__tests__/__snapshots__/config_service.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`correctly passes context 1`] = ` +ExampleClassWithSchema { + "value": Object { + "branchRef": "feature-v1", + "buildNumRef": 100, + "buildShaRef": "feature-v1-build-sha", + "devRef": true, + "prodRef": false, + "versionRef": "v1", + }, +} +`; + +exports[`throws error if config class does not implement 'schema' 1`] = `[Error: The config class [ExampleClass] did not contain a static 'schema' field, which is required when creating a config instance]`; + +exports[`throws if config at path does not match schema 1`] = `"[key]: expected value of type [string] but got [number]"`; diff --git a/src/core/server/config/__tests__/apply_argv.test.ts b/src/core/server/config/__tests__/apply_argv.test.ts new file mode 100644 index 00000000000000..9fd36b308f049e --- /dev/null +++ b/src/core/server/config/__tests__/apply_argv.test.ts @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ObjectToRawConfigAdapter, RawConfig } from '..'; + +/** + * Overrides some config values with ones from argv. + * + * @param config `RawConfig` instance to update config values for. + * @param argv Argv object with key/value pairs. + */ +export function overrideConfigWithArgv(config: RawConfig, argv: { [key: string]: any }) { + if (argv.port != null) { + config.set(['server', 'port'], argv.port); + } + + if (argv.host != null) { + config.set(['server', 'host'], argv.host); + } + + return config; +} + +test('port', () => { + const argv = { + port: 123, + }; + + const config = new ObjectToRawConfigAdapter({ + server: { port: 456 }, + }); + + overrideConfigWithArgv(config, argv); + + expect(config.get('server.port')).toEqual(123); +}); + +test('host', () => { + const argv = { + host: 'example.org', + }; + + const config = new ObjectToRawConfigAdapter({ + server: { host: 'org.example' }, + }); + + overrideConfigWithArgv(config, argv); + + expect(config.get('server.host')).toEqual('example.org'); +}); + +test('ignores unknown', () => { + const argv = { + unknown: 'some value', + }; + + const config = new ObjectToRawConfigAdapter({}); + jest.spyOn(config, 'set'); + + overrideConfigWithArgv(config, argv); + + expect(config.set).not.toHaveBeenCalled(); +}); diff --git a/src/core/server/config/__tests__/config_service.test.ts b/src/core/server/config/__tests__/config_service.test.ts new file mode 100644 index 00000000000000..e676de9e2c0e68 --- /dev/null +++ b/src/core/server/config/__tests__/config_service.test.ts @@ -0,0 +1,283 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* tslint:disable max-classes-per-file */ +import { BehaviorSubject, first, k$, toPromise } from '../../../lib/kbn_observable'; +import { AnyType, schema, TypeOf } from '../schema'; + +import { ConfigService, ObjectToRawConfigAdapter } from '..'; +import { logger } from '../../logging/__mocks__'; +import { Env } from '../env'; +import { getEnvOptions } from './__mocks__/env'; + +const emptyArgv = getEnvOptions(); +const defaultEnv = new Env('/kibana', emptyArgv); + +test('returns config at path as observable', async () => { + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ key: 'foo' })); + const configService = new ConfigService(config$, defaultEnv, logger); + + const configs = configService.atPath('key', ExampleClassWithStringSchema); + const exampleConfig = await k$(configs)(first(), toPromise()); + + expect(exampleConfig.value).toBe('foo'); +}); + +test('throws if config at path does not match schema', async () => { + expect.assertions(1); + + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ key: 123 })); + + const configService = new ConfigService(config$, defaultEnv, logger); + const configs = configService.atPath('key', ExampleClassWithStringSchema); + + try { + await k$(configs)(first(), toPromise()); + } catch (e) { + expect(e.message).toMatchSnapshot(); + } +}); + +test("returns undefined if fetching optional config at a path that doesn't exist", async () => { + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ foo: 'bar' })); + const configService = new ConfigService(config$, defaultEnv, logger); + + const configs = configService.optionalAtPath('unique-name', ExampleClassWithStringSchema); + const exampleConfig = await k$(configs)(first(), toPromise()); + + expect(exampleConfig).toBeUndefined(); +}); + +test('returns observable config at optional path if it exists', async () => { + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ value: 'bar' })); + const configService = new ConfigService(config$, defaultEnv, logger); + + const configs = configService.optionalAtPath('value', ExampleClassWithStringSchema); + const exampleConfig: any = await k$(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 () => { + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ key: 'value' })); + const configService = new ConfigService(config$, defaultEnv, logger); + + const valuesReceived: any[] = []; + configService.atPath('key', ExampleClassWithStringSchema).subscribe(config => { + valuesReceived.push(config.value); + }); + + config$.next(new ObjectToRawConfigAdapter({ key: 'value' })); + + expect(valuesReceived).toEqual(['value']); +}); + +test('pushes new config when reloading and config at path has changed', async () => { + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ key: 'value' })); + const configService = new ConfigService(config$, defaultEnv, logger); + + const valuesReceived: any[] = []; + configService.atPath('key', ExampleClassWithStringSchema).subscribe(config => { + valuesReceived.push(config.value); + }); + + config$.next(new ObjectToRawConfigAdapter({ key: 'new value' })); + + expect(valuesReceived).toEqual(['value', 'new value']); +}); + +test("throws error if config class does not implement 'schema'", async () => { + expect.assertions(1); + + class ExampleClass {} + + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ key: 'value' })); + const configService = new ConfigService(config$, defaultEnv, logger); + + const configs = configService.atPath('key', ExampleClass as any); + + try { + await k$(configs)(first(), toPromise()); + } catch (e) { + expect(e).toMatchSnapshot(); + } +}); + +test('tracks unhandled paths', async () => { + const initialConfig = { + bar: { + deep1: { + key: '123', + }, + deep2: { + key: '321', + }, + }, + foo: 'value', + quux: { + deep1: { + key: 'hello', + }, + deep2: { + key: 'world', + }, + }, + }; + + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter(initialConfig)); + const configService = new ConfigService(config$, defaultEnv, logger); + + configService.atPath('foo', createClassWithSchema(schema.string())); + configService.atPath( + ['bar', 'deep2'], + createClassWithSchema( + schema.object({ + key: schema.string(), + }) + ) + ); + + const unused = await configService.getUnusedPaths(); + + expect(unused).toEqual(['bar.deep1.key', 'quux.deep1.key', 'quux.deep2.key']); +}); + +test('correctly passes context', async () => { + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ foo: {} })); + + const env = new Env( + '/kibana', + getEnvOptions({ + mode: 'development', + packageInfo: { + branch: 'feature-v1', + buildNum: 100, + buildSha: 'feature-v1-build-sha', + version: 'v1', + }, + }) + ); + + const configService = new ConfigService(config$, env, logger); + const configs = configService.atPath( + 'foo', + createClassWithSchema( + schema.object({ + branchRef: schema.string({ + defaultValue: schema.contextRef('branch'), + }), + buildNumRef: schema.number({ + defaultValue: schema.contextRef('buildNum'), + }), + buildShaRef: schema.string({ + defaultValue: schema.contextRef('buildSha'), + }), + devRef: schema.boolean({ defaultValue: schema.contextRef('dev') }), + prodRef: schema.boolean({ defaultValue: schema.contextRef('prod') }), + versionRef: schema.string({ + defaultValue: schema.contextRef('version'), + }), + }) + ) + ); + + expect(await k$(configs)(first(), toPromise())).toMatchSnapshot(); +}); + +test('handles enabled path, but only marks the enabled path as used', async () => { + const initialConfig = { + pid: { + enabled: true, + file: '/some/file.pid', + }, + }; + + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter(initialConfig)); + const configService = new ConfigService(config$, defaultEnv, logger); + + const isEnabled = await configService.isEnabledAtPath('pid'); + expect(isEnabled).toBe(true); + + const unusedPaths = await configService.getUnusedPaths(); + expect(unusedPaths).toEqual(['pid.file']); +}); + +test('handles enabled path when path is array', async () => { + const initialConfig = { + pid: { + enabled: true, + file: '/some/file.pid', + }, + }; + + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter(initialConfig)); + const configService = new ConfigService(config$, defaultEnv, logger); + + const isEnabled = await configService.isEnabledAtPath(['pid']); + expect(isEnabled).toBe(true); + + const unusedPaths = await configService.getUnusedPaths(); + expect(unusedPaths).toEqual(['pid.file']); +}); + +test('handles disabled path and marks config as used', async () => { + const initialConfig = { + pid: { + enabled: false, + file: '/some/file.pid', + }, + }; + + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter(initialConfig)); + const configService = new ConfigService(config$, defaultEnv, logger); + + const isEnabled = await configService.isEnabledAtPath('pid'); + expect(isEnabled).toBe(false); + + const unusedPaths = await configService.getUnusedPaths(); + expect(unusedPaths).toEqual([]); +}); + +test('treats config as enabled if config path is not present in config', async () => { + const initialConfig = {}; + + const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter(initialConfig)); + const configService = new ConfigService(config$, defaultEnv, logger); + + const isEnabled = await configService.isEnabledAtPath('pid'); + expect(isEnabled).toBe(true); + + const unusedPaths = await configService.getUnusedPaths(); + expect(unusedPaths).toEqual([]); +}); + +function createClassWithSchema(s: AnyType) { + return class ExampleClassWithSchema { + public static schema = s; + + constructor(readonly value: TypeOf) {} + }; +} + +class ExampleClassWithStringSchema { + public static schema = schema.string(); + + constructor(readonly value: string) {} +} diff --git a/src/core/server/config/__tests__/ensure_deep_object.test.ts b/src/core/server/config/__tests__/ensure_deep_object.test.ts new file mode 100644 index 00000000000000..40c07322660737 --- /dev/null +++ b/src/core/server/config/__tests__/ensure_deep_object.test.ts @@ -0,0 +1,156 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ensureDeepObject } from '../ensure_deep_object'; + +test('flat object', () => { + const obj = { + 'foo.a': 1, + 'foo.b': 2, + }; + + expect(ensureDeepObject(obj)).toEqual({ + foo: { + a: 1, + b: 2, + }, + }); +}); + +test('deep object', () => { + const obj = { + foo: { + a: 1, + b: 2, + }, + }; + + expect(ensureDeepObject(obj)).toEqual({ + foo: { + a: 1, + b: 2, + }, + }); +}); + +test('flat within deep object', () => { + const obj = { + foo: { + b: 2, + 'bar.a': 1, + }, + }; + + expect(ensureDeepObject(obj)).toEqual({ + foo: { + b: 2, + bar: { + a: 1, + }, + }, + }); +}); + +test('flat then flat object', () => { + const obj = { + 'foo.bar': { + b: 2, + 'quux.a': 1, + }, + }; + + expect(ensureDeepObject(obj)).toEqual({ + foo: { + bar: { + b: 2, + quux: { + a: 1, + }, + }, + }, + }); +}); + +test('full with empty array', () => { + const obj = { + a: 1, + b: [], + }; + + expect(ensureDeepObject(obj)).toEqual({ + a: 1, + b: [], + }); +}); + +test('full with array of primitive values', () => { + const obj = { + a: 1, + b: [1, 2, 3], + }; + + expect(ensureDeepObject(obj)).toEqual({ + a: 1, + b: [1, 2, 3], + }); +}); + +test('full with array of full objects', () => { + const obj = { + a: 1, + b: [{ c: 2 }, { d: 3 }], + }; + + expect(ensureDeepObject(obj)).toEqual({ + a: 1, + b: [{ c: 2 }, { d: 3 }], + }); +}); + +test('full with array of flat objects', () => { + const obj = { + a: 1, + b: [{ 'c.d': 2 }, { 'e.f': 3 }], + }; + + expect(ensureDeepObject(obj)).toEqual({ + a: 1, + b: [{ c: { d: 2 } }, { e: { f: 3 } }], + }); +}); + +test('flat with flat and array of flat objects', () => { + const obj = { + a: 1, + 'b.c': 2, + d: [3, { 'e.f': 4 }, { 'g.h': 5 }], + }; + + expect(ensureDeepObject(obj)).toEqual({ + a: 1, + b: { c: 2 }, + d: [3, { e: { f: 4 } }, { g: { h: 5 } }], + }); +}); + +test('array composed of flat objects', () => { + const arr = [{ 'c.d': 2 }, { 'e.f': 3 }]; + + expect(ensureDeepObject(arr)).toEqual([{ c: { d: 2 } }, { e: { f: 3 } }]); +}); diff --git a/src/core/server/config/__tests__/env.test.ts b/src/core/server/config/__tests__/env.test.ts new file mode 100644 index 00000000000000..8707bb2f2a2f71 --- /dev/null +++ b/src/core/server/config/__tests__/env.test.ts @@ -0,0 +1,104 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('process', () => ({ + cwd() { + return '/test/cwd'; + }, +})); + +jest.mock('path', () => ({ + resolve(...pathSegments: string[]) { + return pathSegments.join('/'); + }, +})); + +import { Env } from '../env'; +import { getEnvOptions } from './__mocks__/env'; + +test('correctly creates default environment with empty options.', () => { + const envOptions = getEnvOptions(); + const defaultEnv = Env.createDefault(envOptions); + + expect(defaultEnv.homeDir).toEqual('/test/cwd'); + expect(defaultEnv.configDir).toEqual('/test/cwd/config'); + expect(defaultEnv.corePluginsDir).toEqual('/test/cwd/core_plugins'); + expect(defaultEnv.binDir).toEqual('/test/cwd/bin'); + expect(defaultEnv.logDir).toEqual('/test/cwd/log'); + expect(defaultEnv.staticFilesDir).toEqual('/test/cwd/ui'); + + expect(defaultEnv.getConfigFile()).toEqual('/test/cwd/config/kibana.yml'); + expect(defaultEnv.getLegacyKbnServer()).toBeUndefined(); + expect(defaultEnv.getMode()).toEqual(envOptions.mode); + expect(defaultEnv.getPackageInfo()).toEqual(envOptions.packageInfo); +}); + +test('correctly creates default environment with options overrides.', () => { + const mockEnvOptions = getEnvOptions({ + config: '/some/other/path/some-kibana.yml', + kbnServer: {}, + mode: 'production', + packageInfo: { + branch: 'feature-v1', + buildNum: 100, + buildSha: 'feature-v1-build-sha', + version: 'v1', + }, + }); + const defaultEnv = Env.createDefault(mockEnvOptions); + + expect(defaultEnv.homeDir).toEqual('/test/cwd'); + expect(defaultEnv.configDir).toEqual('/test/cwd/config'); + expect(defaultEnv.corePluginsDir).toEqual('/test/cwd/core_plugins'); + expect(defaultEnv.binDir).toEqual('/test/cwd/bin'); + expect(defaultEnv.logDir).toEqual('/test/cwd/log'); + expect(defaultEnv.staticFilesDir).toEqual('/test/cwd/ui'); + + expect(defaultEnv.getConfigFile()).toEqual(mockEnvOptions.config); + expect(defaultEnv.getLegacyKbnServer()).toBe(mockEnvOptions.kbnServer); + expect(defaultEnv.getMode()).toEqual(mockEnvOptions.mode); + expect(defaultEnv.getPackageInfo()).toEqual(mockEnvOptions.packageInfo); +}); + +test('correctly creates environment with constructor.', () => { + const mockEnvOptions = getEnvOptions({ + config: '/some/other/path/some-kibana.yml', + mode: 'production', + packageInfo: { + branch: 'feature-v1', + buildNum: 100, + buildSha: 'feature-v1-build-sha', + version: 'v1', + }, + }); + + const defaultEnv = new Env('/some/home/dir', mockEnvOptions); + + expect(defaultEnv.homeDir).toEqual('/some/home/dir'); + expect(defaultEnv.configDir).toEqual('/some/home/dir/config'); + expect(defaultEnv.corePluginsDir).toEqual('/some/home/dir/core_plugins'); + expect(defaultEnv.binDir).toEqual('/some/home/dir/bin'); + expect(defaultEnv.logDir).toEqual('/some/home/dir/log'); + expect(defaultEnv.staticFilesDir).toEqual('/some/home/dir/ui'); + + expect(defaultEnv.getConfigFile()).toEqual(mockEnvOptions.config); + expect(defaultEnv.getLegacyKbnServer()).toBeUndefined(); + expect(defaultEnv.getMode()).toEqual(mockEnvOptions.mode); + expect(defaultEnv.getPackageInfo()).toEqual(mockEnvOptions.packageInfo); +}); diff --git a/src/core/server/config/__tests__/raw_config_service.test.ts b/src/core/server/config/__tests__/raw_config_service.test.ts new file mode 100644 index 00000000000000..2dfc52c1c2b8dd --- /dev/null +++ b/src/core/server/config/__tests__/raw_config_service.test.ts @@ -0,0 +1,132 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const mockGetConfigFromFile = jest.fn(); + +jest.mock('../read_config', () => ({ + getConfigFromFile: mockGetConfigFromFile, +})); + +import { first, k$, toPromise } from '../../../lib/kbn_observable'; +import { RawConfigService } from '../raw_config_service'; + +const configFile = '/config/kibana.yml'; + +beforeEach(() => { + mockGetConfigFromFile.mockReset(); + mockGetConfigFromFile.mockImplementation(() => ({})); +}); + +test('loads raw config when started', () => { + const configService = new RawConfigService(configFile); + + configService.loadConfig(); + + expect(mockGetConfigFromFile).toHaveBeenCalledTimes(1); + expect(mockGetConfigFromFile).toHaveBeenLastCalledWith(configFile); +}); + +test('re-reads the config when reloading', () => { + const configService = new RawConfigService(configFile); + + configService.loadConfig(); + + mockGetConfigFromFile.mockClear(); + mockGetConfigFromFile.mockImplementation(() => ({ foo: 'bar' })); + + configService.reloadConfig(); + + expect(mockGetConfigFromFile).toHaveBeenCalledTimes(1); + expect(mockGetConfigFromFile).toHaveBeenLastCalledWith(configFile); +}); + +test('returns config at path as observable', async () => { + mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + + const configService = new RawConfigService(configFile); + + configService.loadConfig(); + + const exampleConfig = await k$(configService.getConfig$())(first(), toPromise()); + + expect(exampleConfig.get('key')).toEqual('value'); + expect(exampleConfig.getFlattenedPaths()).toEqual(['key']); +}); + +test("does not push new configs when reloading if config at path hasn't changed", async () => { + mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + + const configService = new RawConfigService(configFile); + + configService.loadConfig(); + + const valuesReceived: any[] = []; + configService.getConfig$().subscribe(config => { + valuesReceived.push(config); + }); + + mockGetConfigFromFile.mockClear(); + mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + + configService.reloadConfig(); + + expect(valuesReceived).toHaveLength(1); + expect(valuesReceived[0].get('key')).toEqual('value'); + expect(valuesReceived[0].getFlattenedPaths()).toEqual(['key']); +}); + +test('pushes new config when reloading and config at path has changed', async () => { + mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + + const configService = new RawConfigService(configFile); + + configService.loadConfig(); + + const valuesReceived: any[] = []; + configService.getConfig$().subscribe(config => { + valuesReceived.push(config); + }); + + mockGetConfigFromFile.mockClear(); + mockGetConfigFromFile.mockImplementation(() => ({ key: 'new value' })); + + configService.reloadConfig(); + + expect(valuesReceived).toHaveLength(2); + expect(valuesReceived[0].get('key')).toEqual('value'); + expect(valuesReceived[0].getFlattenedPaths()).toEqual(['key']); + expect(valuesReceived[1].get('key')).toEqual('new value'); + expect(valuesReceived[1].getFlattenedPaths()).toEqual(['key']); +}); + +test('completes config observables when stopped', done => { + expect.assertions(0); + + mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + + const configService = new RawConfigService(configFile); + + configService.loadConfig(); + + configService.getConfig$().subscribe({ + complete: () => done(), + }); + + configService.stop(); +}); diff --git a/src/core/server/config/__tests__/read_config.test.ts b/src/core/server/config/__tests__/read_config.test.ts new file mode 100644 index 00000000000000..74683f4227929f --- /dev/null +++ b/src/core/server/config/__tests__/read_config.test.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getConfigFromFile } from '../read_config'; + +const fixtureFile = (name: string) => `${__dirname}/__fixtures__/${name}`; + +test('reads yaml from file system and parses to json', () => { + const config = getConfigFromFile(fixtureFile('config.yml')); + + expect(config).toEqual({ + pid: { + enabled: true, + file: '/var/run/kibana.pid', + }, + }); +}); + +test('returns a deep object', () => { + const config = getConfigFromFile(fixtureFile('/config_flat.yml')); + + expect(config).toEqual({ + pid: { + enabled: true, + file: '/var/run/kibana.pid', + }, + }); +}); diff --git a/src/core/server/config/config_service.ts b/src/core/server/config/config_service.ts new file mode 100644 index 00000000000000..c96c3764fb2312 --- /dev/null +++ b/src/core/server/config/config_service.ts @@ -0,0 +1,179 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isEqual } from 'lodash'; +import { first, k$, map, Observable, skipRepeats, toPromise } from '../../lib/kbn_observable'; + +import { Logger, LoggerFactory } from '../logging'; +import { ConfigWithSchema } from './config_with_schema'; +import { Env } from './env'; +import { RawConfig } from './raw_config'; +import { AnyType } from './schema'; + +export type ConfigPath = string | string[]; + +export class ConfigService { + 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 config$: Observable, + readonly env: Env, + logger: LoggerFactory + ) { + this.log = logger.get('config'); + } + + /** + * Returns the full config object observable. This is not intended for + * "normal use", but for features that _need_ access to the full object. + */ + public getConfig$() { + return this.config$; + } + + /** + * Reads the subset of the config at the specified `path` and validates it + * against the static `schema` on the given `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 `schema` that we validate the config at the given `path` against. + */ + public atPath( + path: ConfigPath, + ConfigClass: ConfigWithSchema + ) { + return k$(this.getDistinctRawConfig(path))( + map(rawConfig => this.createConfig(path, rawConfig, ConfigClass)) + ); + } + + /** + * Same as `atPath`, but returns `undefined` if there is no config at the + * specified path. + * + * @see atPath + */ + public optionalAtPath( + path: ConfigPath, + ConfigClass: ConfigWithSchema + ) { + return k$(this.getDistinctRawConfig(path))( + map( + rawConfig => + rawConfig === undefined ? undefined : this.createConfig(path, rawConfig, ConfigClass) + ) + ); + } + + public async isEnabledAtPath(path: ConfigPath) { + const enabledPath = createPluginEnabledPath(path); + + const config = await k$(this.config$)(first(), toPromise()); + + if (!config.has(enabledPath)) { + return true; + } + + const isEnabled = config.get(enabledPath); + + if (isEnabled === false) { + // If the plugin is _not_ enabled, we mark the entire plugin path as + // handled, as it's expected that it won't be used. + this.markAsHandled(path); + return false; + } + + // If plugin enabled we mark the enabled path as handled, as we for example + // can have plugins that don't have _any_ config except for this field, and + // therefore have no reason to try to get the config. + this.markAsHandled(enabledPath); + return true; + } + + public async getUnusedPaths(): Promise { + const config = await k$(this.config$)(first(), toPromise()); + const handledPaths = this.handledPaths.map(pathToString); + + return config.getFlattenedPaths().filter(path => !isPathHandled(path, handledPaths)); + } + + private createConfig( + path: ConfigPath, + rawConfig: {}, + ConfigClass: ConfigWithSchema + ) { + const namespace = Array.isArray(path) ? path.join('.') : path; + + const configSchema = ConfigClass.schema; + + if (configSchema === undefined || typeof configSchema.validate !== 'function') { + throw new Error( + `The config class [${ + ConfigClass.name + }] did not contain a static 'schema' field, which is required when creating a config instance` + ); + } + + const environmentMode = this.env.getMode(); + const config = ConfigClass.schema.validate( + rawConfig, + { + dev: environmentMode.dev, + prod: environmentMode.prod, + ...this.env.getPackageInfo(), + }, + namespace + ); + return new ConfigClass(config, this.env); + } + + private getDistinctRawConfig(path: ConfigPath) { + this.markAsHandled(path); + + return k$(this.config$)(map(config => config.get(path)), skipRepeats(isEqual)); + } + + private markAsHandled(path: ConfigPath) { + this.log.debug(`Marking config path as handled: ${path}`); + this.handledPaths.push(path); + } +} + +const createPluginEnabledPath = (configPath: string | string[]) => { + if (Array.isArray(configPath)) { + return configPath.concat('enabled'); + } + return `${configPath}.enabled`; +}; + +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)); diff --git a/src/core/server/config/config_with_schema.ts b/src/core/server/config/config_with_schema.ts new file mode 100644 index 00000000000000..f049d28fa9787f --- /dev/null +++ b/src/core/server/config/config_with_schema.ts @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// TODO inline all of these +import { Env } from './env'; +import { AnyType, TypeOf } from './schema'; + +/** + * Interface that defines the static side of a config class. + * + * (Remember that a class has two types: the type of the static side and the + * type of the instance side, see https://www.typescriptlang.org/docs/handbook/interfaces.html#difference-between-the-static-and-instance-sides-of-classes) + * + * This can't be used to define the config class because of how interfaces work + * in TypeScript, but it can be used to ensure we have a config class that + * matches whenever it's used. + */ +export interface ConfigWithSchema { + /** + * Any config class must define a schema that validates the config, based on + * the injected `schema` helper. + */ + schema: S; + + /** + * @param validatedConfig The result of validating the static `schema` above. + * @param env An instance of the `Env` class that defines environment specific + * variables. + */ + new (validatedConfig: TypeOf, env: Env): Config; +} diff --git a/src/core/server/config/ensure_deep_object.ts b/src/core/server/config/ensure_deep_object.ts new file mode 100644 index 00000000000000..0b24190741b10b --- /dev/null +++ b/src/core/server/config/ensure_deep_object.ts @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const separator = '.'; + +/** + * Recursively traverses through the object's properties and expands ones with + * dot-separated names into nested objects (eg. { a.b: 'c'} -> { a: { b: 'c' }). + * @param obj Object to traverse through. + * @returns Same object instance with expanded properties. + */ +export function ensureDeepObject(obj: any): any { + if (obj == null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => ensureDeepObject(item)); + } + + return Object.keys(obj).reduce( + (fullObject, propertyKey) => { + const propertyValue = obj[propertyKey]; + if (!propertyKey.includes(separator)) { + fullObject[propertyKey] = ensureDeepObject(propertyValue); + } else { + walk(fullObject, propertyKey.split(separator), propertyValue); + } + + return fullObject; + }, + {} as any + ); +} + +function walk(obj: any, keys: string[], value: any) { + const key = keys.shift()!; + if (keys.length === 0) { + obj[key] = value; + return; + } + + if (obj[key] === undefined) { + obj[key] = {}; + } + + walk(obj[key], keys, ensureDeepObject(value)); +} diff --git a/src/core/server/config/env.ts b/src/core/server/config/env.ts new file mode 100644 index 00000000000000..87e4b6567120b0 --- /dev/null +++ b/src/core/server/config/env.ts @@ -0,0 +1,100 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'path'; +import process from 'process'; + +import { LegacyKbnServer } from '../legacy_compat'; + +interface PackageInfo { + version: string; + branch: string; + buildNum: number; + buildSha: string; +} + +interface EnvironmentMode { + name: 'development' | 'production'; + dev: boolean; + prod: boolean; +} + +export interface EnvOptions { + config?: string; + kbnServer?: any; + packageInfo: PackageInfo; + mode: EnvironmentMode; + [key: string]: any; +} + +export class Env { + /** + * @internal + */ + public static createDefault(options: EnvOptions): Env { + return new Env(process.cwd(), options); + } + + public readonly configDir: string; + public readonly corePluginsDir: string; + public readonly binDir: string; + public readonly logDir: string; + public readonly staticFilesDir: string; + + /** + * @internal + */ + constructor(readonly homeDir: string, private readonly options: EnvOptions) { + this.configDir = resolve(this.homeDir, 'config'); + this.corePluginsDir = resolve(this.homeDir, 'core_plugins'); + this.binDir = resolve(this.homeDir, 'bin'); + this.logDir = resolve(this.homeDir, 'log'); + this.staticFilesDir = resolve(this.homeDir, 'ui'); + } + + public getConfigFile() { + const defaultConfigFile = this.getDefaultConfigFile(); + return this.options.config === undefined ? defaultConfigFile : this.options.config; + } + + /** + * @internal + */ + public getLegacyKbnServer(): LegacyKbnServer | undefined { + return this.options.kbnServer; + } + + /** + * Gets information about Kibana package (version, build number etc.). + */ + public getPackageInfo() { + return this.options.packageInfo; + } + + /** + * Gets mode Kibana currently run in (development or production). + */ + public getMode() { + return this.options.mode; + } + + private getDefaultConfigFile() { + return resolve(this.configDir, 'kibana.yml'); + } +} diff --git a/src/core/server/config/index.ts b/src/core/server/config/index.ts new file mode 100644 index 00000000000000..030fdca252d33d --- /dev/null +++ b/src/core/server/config/index.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * This is a name of configuration node that is specifically dedicated to + * the configuration values used by the new platform only. Eventually all + * its nested values will be migrated to the stable config and this node + * will be deprecated. + */ +export const NEW_PLATFORM_CONFIG_ROOT = '__newPlatform'; + +export { ConfigService } from './config_service'; +export { RawConfigService } from './raw_config_service'; +export { RawConfig } from './raw_config'; +/** @internal */ +export { ObjectToRawConfigAdapter } from './object_to_raw_config_adapter'; +export { Env } from './env'; +export { ConfigWithSchema } from './config_with_schema'; diff --git a/src/core/server/config/object_to_raw_config_adapter.ts b/src/core/server/config/object_to_raw_config_adapter.ts new file mode 100644 index 00000000000000..3abd73f01fcb9a --- /dev/null +++ b/src/core/server/config/object_to_raw_config_adapter.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get, has, set } from 'lodash'; + +import { ConfigPath } from './config_service'; +import { RawConfig } from './raw_config'; + +/** + * Allows plain javascript object to behave like `RawConfig` instance. + * @internal + */ +export class ObjectToRawConfigAdapter implements RawConfig { + constructor(private readonly rawValue: { [key: string]: any }) {} + + public has(configPath: ConfigPath) { + return has(this.rawValue, configPath); + } + + public get(configPath: ConfigPath) { + return get(this.rawValue, configPath); + } + + public set(configPath: ConfigPath, value: any) { + set(this.rawValue, configPath, value); + } + + public getFlattenedPaths() { + return [...flattenObjectKeys(this.rawValue)]; + } +} + +function* flattenObjectKeys( + obj: { [key: string]: any }, + path: string = '' +): IterableIterator { + if (typeof obj !== 'object' || obj === null) { + yield path; + } else { + for (const [key, value] of Object.entries(obj)) { + const newPath = path !== '' ? `${path}.${key}` : key; + yield* flattenObjectKeys(value, newPath); + } + } +} diff --git a/src/core/server/config/raw_config.ts b/src/core/server/config/raw_config.ts new file mode 100644 index 00000000000000..c87a6fe5768a5a --- /dev/null +++ b/src/core/server/config/raw_config.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ConfigPath } from './config_service'; + +/** + * Represents raw config store. + */ +export interface RawConfig { + /** + * Returns whether or not there is a config value located at the specified path. + * @param configPath Path to locate value at. + * @returns Whether or not a value exists at the path. + */ + has(configPath: ConfigPath): boolean; + + /** + * Returns config value located at the specified path. + * @param configPath Path to locate value at. + * @returns Config value. + */ + get(configPath: ConfigPath): any; + + /** + * Sets config value at the specified path. + * @param configPath Path to set value for. + * @param value Value to set for the specified path. + */ + set(configPath: ConfigPath, value: any): void; + + /** + * Returns full flattened list of the config paths that config contains. + * @returns List of the string config paths. + */ + getFlattenedPaths(): string[]; +} diff --git a/src/core/server/config/raw_config_service.ts b/src/core/server/config/raw_config_service.ts new file mode 100644 index 00000000000000..6cf2b9b2678c0a --- /dev/null +++ b/src/core/server/config/raw_config_service.ts @@ -0,0 +1,97 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isEqual, isPlainObject } from 'lodash'; +import typeDetect from 'type-detect'; +import { + BehaviorSubject, + filter, + k$, + map, + Observable, + skipRepeats, +} from '../../lib/kbn_observable'; + +import { ObjectToRawConfigAdapter } from './object_to_raw_config_adapter'; +import { RawConfig } from './raw_config'; +import { getConfigFromFile } from './read_config'; + +// Used to indicate that no config has been received yet +const notRead = Symbol('config not yet read'); + +export class RawConfigService { + /** + * The stream of configs read from the config file. Will be the symbol + * `notRead` before the config is initially read, and after that it can + * potentially be `null` for an empty yaml file. + * + * This is the _raw_ config before any overrides are 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(notRead); + + private readonly config$: Observable; + + constructor(readonly configFile: string) { + this.config$ = k$(this.rawConfigFromFile$)( + filter(rawConfig => rawConfig !== notRead), + map(rawConfig => { + // If the raw config is null, e.g. if empty config file, we default to + // an empty config + if (rawConfig == null) { + return new ObjectToRawConfigAdapter({}); + } + + if (isPlainObject(rawConfig)) { + // TODO Make config consistent, e.g. handle dots in keys + return new ObjectToRawConfigAdapter(rawConfig); + } + + throw new Error(`the raw config must be an object, got [${typeDetect(rawConfig)}]`); + }), + // We only want to update the config if there are changes to it + skipRepeats(isEqual) + ); + } + + /** + * Read the initial Kibana config. + */ + public loadConfig() { + const config = getConfigFromFile(this.configFile); + this.rawConfigFromFile$.next(config); + } + + public stop() { + this.rawConfigFromFile$.complete(); + } + + /** + * Re-read the Kibana config. + */ + public reloadConfig() { + this.loadConfig(); + } + + public getConfig$() { + return this.config$; + } +} diff --git a/src/core/server/config/read_config.ts b/src/core/server/config/read_config.ts new file mode 100644 index 00000000000000..c1b8ce930af2e7 --- /dev/null +++ b/src/core/server/config/read_config.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFileSync } from 'fs'; +import { safeLoad } from 'js-yaml'; + +import { ensureDeepObject } from './ensure_deep_object'; + +const readYaml = (path: string) => safeLoad(readFileSync(path, 'utf8')); + +export const getConfigFromFile = (configFile: string) => { + const yaml = readYaml(configFile); + return yaml == null ? yaml : ensureDeepObject(yaml); +}; diff --git a/src/core/server/config/schema/byte_size_value/__tests__/__snapshots__/index.test.ts.snap b/src/core/server/config/schema/byte_size_value/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000000000..1db6930062a9a1 --- /dev/null +++ b/src/core/server/config/schema/byte_size_value/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#constructor throws if number of bytes is negative 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-1024]"`; + +exports[`#constructor throws if number of bytes is not safe 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]"`; + +exports[`#constructor throws if number of bytes is not safe 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]"`; + +exports[`#constructor throws if number of bytes is not safe 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]"`; diff --git a/src/core/server/config/schema/byte_size_value/__tests__/index.test.ts b/src/core/server/config/schema/byte_size_value/__tests__/index.test.ts new file mode 100644 index 00000000000000..ece87692481527 --- /dev/null +++ b/src/core/server/config/schema/byte_size_value/__tests__/index.test.ts @@ -0,0 +1,132 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +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('#constructor', () => { + test('throws if number of bytes is negative', () => { + expect(() => new ByteSizeValue(-1024)).toThrowErrorMatchingSnapshot(); + }); + + test('throws if number of bytes is not safe', () => { + expect(() => new ByteSizeValue(NaN)).toThrowErrorMatchingSnapshot(); + expect(() => new ByteSizeValue(Infinity)).toThrowErrorMatchingSnapshot(); + expect(() => new ByteSizeValue(Math.pow(2, 53))).toThrowErrorMatchingSnapshot(); + }); + + test('accepts 0', () => { + const value = new ByteSizeValue(0); + expect(value.getValueInBytes()).toBe(0); + }); + + test('accepts safe positive integer', () => { + const value = new ByteSizeValue(1024); + expect(value.getValueInBytes()).toBe(1024); + }); +}); + +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/src/core/server/config/schema/byte_size_value/index.ts b/src/core/server/config/schema/byte_size_value/index.ts new file mode 100644 index 00000000000000..61ba879a5c9262 --- /dev/null +++ b/src/core/server/config/schema/byte_size_value/index.ts @@ -0,0 +1,108 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type ByteSizeValueUnit = 'b' | 'kb' | 'mb' | 'gb'; + +const unitMultiplier: { [unit: string]: number } = { + b: Math.pow(1024, 0), + gb: Math.pow(1024, 3), + kb: Math.pow(1024, 1), + mb: Math.pow(1024, 2), +}; + +function renderUnit(value: number, unit: string) { + const prettyValue = Number(value.toFixed(2)); + return `${prettyValue}${unit}`; +} + +export class ByteSizeValue { + public 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], 0); + const unit = match[2]; + + return new ByteSizeValue(value * unitMultiplier[unit]); + } + + constructor(private readonly valueInBytes: number) { + if (!Number.isSafeInteger(valueInBytes) || valueInBytes < 0) { + throw new Error( + `Value in bytes is expected to be a safe positive integer, ` + + `but provided [${valueInBytes}]` + ); + } + } + + public isGreaterThan(other: ByteSizeValue): boolean { + return this.valueInBytes > other.valueInBytes; + } + + public isLessThan(other: ByteSizeValue): boolean { + return this.valueInBytes < other.valueInBytes; + } + + public isEqualTo(other: ByteSizeValue): boolean { + return this.valueInBytes === other.valueInBytes; + } + + public getValueInBytes(): number { + return this.valueInBytes; + } + + public 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); + } +} + +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); + +export function ensureByteSizeValue(value?: ByteSizeValue | string | number) { + if (typeof value === 'string') { + return ByteSizeValue.parse(value); + } + + if (typeof value === 'number') { + return new ByteSizeValue(value); + } + + return value; +} diff --git a/src/core/server/config/schema/duration/index.ts b/src/core/server/config/schema/duration/index.ts new file mode 100644 index 00000000000000..58caa417a67bac --- /dev/null +++ b/src/core/server/config/schema/duration/index.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Duration, duration as momentDuration, DurationInputArg2, isDuration } from 'moment'; +export { Duration, isDuration }; + +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], 0); + const unit = result[2] as DurationInputArg2; + + return momentDuration(count, unit); +} + +function numberToDuration(numberMs: number) { + if (!Number.isSafeInteger(numberMs) || numberMs < 0) { + throw new Error( + `Failed to parse [${numberMs}] as time value. ` + + `Value should be a safe positive integer number.` + ); + } + + return momentDuration(numberMs); +} + +export function ensureDuration(value?: Duration | string | number) { + if (typeof value === 'string') { + return stringToDuration(value); + } + + if (typeof value === 'number') { + return numberToDuration(value); + } + + return value; +} diff --git a/src/core/server/config/schema/errors/__tests__/schema_error.test.ts b/src/core/server/config/schema/errors/__tests__/schema_error.test.ts new file mode 100644 index 00000000000000..15ce626621b58a --- /dev/null +++ b/src/core/server/config/schema/errors/__tests__/schema_error.test.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { relative } from 'path'; +import { SchemaError } from '..'; + +/** + * 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'); + +// TODO This is skipped because it fails depending on Node version. That might +// not be a problem, but I think we should wait with including this test until +// we've made a proper decision around error handling in the new platform, see +// https://github.com/elastic/kibana/issues/12947 +test.skip('includes stack', () => { + try { + throw new SchemaError('test'); + } catch (e) { + expect(cleanStack(e.stack)).toMatchSnapshot(); + } +}); diff --git a/src/core/server/config/schema/errors/index.ts b/src/core/server/config/schema/errors/index.ts new file mode 100644 index 00000000000000..15e4dca5c8a058 --- /dev/null +++ b/src/core/server/config/schema/errors/index.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { SchemaError } from './schema_error'; +export { SchemaTypeError } from './schema_type_error'; +export { SchemaTypesError } from './schema_types_error'; +export { ValidationError } from './validation_error'; diff --git a/src/core/server/config/schema/errors/schema_error.ts b/src/core/server/config/schema/errors/schema_error.ts new file mode 100644 index 00000000000000..c7cfc651df61ae --- /dev/null +++ b/src/core/server/config/schema/errors/schema_error.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export class SchemaError extends Error { + public cause?: Error; + + constructor(message: string, cause?: Error) { + super(message); + this.cause = cause; + + // Set the prototype explicitly, see: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf(this, SchemaError.prototype); + } +} diff --git a/src/core/server/config/schema/errors/schema_type_error.ts b/src/core/server/config/schema/errors/schema_type_error.ts new file mode 100644 index 00000000000000..895a4072df3bf3 --- /dev/null +++ b/src/core/server/config/schema/errors/schema_type_error.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SchemaError } from '.'; + +export class SchemaTypeError extends SchemaError { + constructor(error: Error | string, public readonly path: string[]) { + super(typeof error === 'string' ? error : error.message); + + // Set the prototype explicitly, see: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf(this, SchemaTypeError.prototype); + } +} diff --git a/src/core/server/config/schema/errors/schema_types_error.ts b/src/core/server/config/schema/errors/schema_types_error.ts new file mode 100644 index 00000000000000..c23961aa2630e6 --- /dev/null +++ b/src/core/server/config/schema/errors/schema_types_error.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SchemaTypeError } from '.'; + +export class SchemaTypesError extends SchemaTypeError { + constructor(error: Error | string, path: string[], public readonly errors: SchemaTypeError[]) { + super(error, path); + + // Set the prototype explicitly, see: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf(this, SchemaTypesError.prototype); + } +} diff --git a/src/core/server/config/schema/errors/validation_error.ts b/src/core/server/config/schema/errors/validation_error.ts new file mode 100644 index 00000000000000..39aac67c208f57 --- /dev/null +++ b/src/core/server/config/schema/errors/validation_error.ts @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SchemaError, SchemaTypeError, SchemaTypesError } from '.'; + +export class ValidationError extends SchemaError { + public static extractMessage(error: SchemaTypeError, namespace?: string) { + const path = typeof namespace === 'string' ? [namespace, ...error.path] : error.path; + + let message = error.message; + if (error instanceof SchemaTypesError) { + const childErrorMessages = error.errors.map(childError => + ValidationError.extractMessage(childError, namespace) + ); + + message = `${message}\n${childErrorMessages + .map(childErrorMessage => `- ${childErrorMessage}`) + .join('\n')}`; + } + + if (path.length === 0) { + return message; + } + + return `[${path.join('.')}]: ${message}`; + } + + constructor(error: SchemaTypeError, namespace?: string) { + super(ValidationError.extractMessage(error, namespace), error); + } +} diff --git a/src/core/server/config/schema/index.ts b/src/core/server/config/schema/index.ts new file mode 100644 index 00000000000000..88decc2356b15c --- /dev/null +++ b/src/core/server/config/schema/index.ts @@ -0,0 +1,176 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Duration } from 'moment'; + +import { ByteSizeValue } from './byte_size_value'; +import { ContextReference, Reference, SiblingReference } from './references'; +import { + AnyType, + ArrayOptions, + ArrayType, + BooleanType, + ByteSizeOptions, + ByteSizeType, + ConditionalType, + DurationOptions, + DurationType, + LiteralType, + MapOfOptions, + MapOfType, + MaybeType, + NumberOptions, + NumberType, + ObjectType, + Props, + StringOptions, + StringType, + Type, + TypeOf, + TypeOptions, + UnionType, +} from './types'; + +export { AnyType, ObjectType, TypeOf }; +export { ByteSizeValue } from './byte_size_value'; + +function boolean(options?: TypeOptions): Type { + return new BooleanType(options); +} + +function string(options?: StringOptions): Type { + return new StringType(options); +} + +function literal(value: T): Type { + return new LiteralType(value); +} + +function number(options?: NumberOptions): Type { + return new NumberType(options); +} + +function byteSize(options?: ByteSizeOptions): Type { + return new ByteSizeType(options); +} + +function duration(options?: DurationOptions): Type { + return new DurationType(options); +} + +/** + * Create an optional type + */ +function maybe(type: Type): Type { + return new MaybeType(type); +} + +function object

( + props: P, + options?: TypeOptions<{ [K in keyof P]: TypeOf }> +): ObjectType

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

extends Type> { + constructor(props: P, options: TypeOptions<{ [K in keyof P]: TypeOf }> = {}) { + const schemaKeys = {} as Record; + for (const [key, value] of Object.entries(props)) { + schemaKeys[key] = value.getSchema(); + } + + const schema = internals + .object() + .keys(schemaKeys) + .optional() + .default(); + + super(schema, options); + } + + protected handleError(type: string, { reason, value }: Record) { + switch (type) { + case 'any.required': + case 'object.base': + return `expected a plain object value, but found [${typeDetect(value)}] instead.`; + case 'object.allowUnknown': + return `definition for this key is missing`; + case 'object.child': + return reason[0]; + } + } +} diff --git a/src/core/server/config/schema/types/string_type.ts b/src/core/server/config/schema/types/string_type.ts new file mode 100644 index 00000000000000..4e7eb5cc229a5f --- /dev/null +++ b/src/core/server/config/schema/types/string_type.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { internals } from '../internals'; +import { Type, TypeOptions } from './type'; + +export type StringOptions = TypeOptions & { + minLength?: number; + maxLength?: number; +}; + +export class StringType extends Type { + constructor(options: StringOptions = {}) { + let schema = internals.string().allow(''); + + if (options.minLength !== undefined) { + schema = schema.min(options.minLength); + } + + if (options.maxLength !== undefined) { + schema = schema.max(options.maxLength); + } + + super(schema, options); + } + + protected handleError(type: string, { limit, value }: Record) { + switch (type) { + case 'any.required': + case 'string.base': + return `expected value of type [string] but got [${typeDetect(value)}]`; + case 'string.min': + return `value is [${value}] but it must have a minimum length of [${limit}].`; + case 'string.max': + return `value is [${value}] but it must have a maximum length of [${limit}].`; + } + } +} diff --git a/src/core/server/config/schema/types/type.ts b/src/core/server/config/schema/types/type.ts new file mode 100644 index 00000000000000..b7db6d71668aed --- /dev/null +++ b/src/core/server/config/schema/types/type.ts @@ -0,0 +1,127 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SchemaTypeError, ValidationError } from '../errors'; +import { AnySchema, internals, ValidationErrorItem } from '../internals'; +import { Reference } from '../references'; + +export interface TypeOptions { + defaultValue?: T | Reference | (() => T); + validate?: (value: T) => string | void; +} + +export abstract class Type { + // This is just to enable the `TypeOf` helper, and because TypeScript would + // fail if it wasn't initialized we use a "trick" to which basically just + // sets the value to `null` while still keeping the type. + public readonly type: V = null! as V; + + /** + * Internal "schema" backed by Joi. + * @type {Schema} + */ + protected readonly internalSchema: AnySchema; + + constructor(schema: AnySchema, options: TypeOptions = {}) { + if (options.defaultValue !== undefined) { + schema = schema.optional(); + + // If default value is a function, then we must provide description for it. + if (typeof options.defaultValue === 'function') { + schema = schema.default(options.defaultValue, 'Type default value'); + } else { + schema = schema.default( + Reference.isReference(options.defaultValue) + ? options.defaultValue.getSchema() + : options.defaultValue + ); + } + } + + if (options.validate) { + schema = schema.custom(options.validate); + } + + // Attach generic error handler only if it hasn't been attached yet since + // only the last error handler is counted. + const schemaFlags = (schema.describe().flags as Record) || {}; + if (schemaFlags.error === undefined) { + schema = schema.error!(([error]) => this.onError(error)); + } + + this.internalSchema = schema; + } + + public validate(value: any, context: Record = {}, namespace?: string): V { + const { value: validatedValue, error } = internals.validate(value, this.internalSchema, { + context, + presence: 'required', + }); + + if (error) { + throw new ValidationError(error as any, namespace); + } + + return validatedValue; + } + + public getSchema() { + return this.internalSchema; + } + + protected handleError( + type: string, + context: Record, + path: string[] + ): string | SchemaTypeError | void { + return undefined; + } + + private onError(error: SchemaTypeError | ValidationErrorItem): SchemaTypeError { + if (error instanceof SchemaTypeError) { + return error; + } + + const { context = {}, type, path: rawPath, message } = error; + + // Before v11.0.0 Joi reported paths as `.`-delimited strings, but more + // recent version use arrays instead. Once we upgrade Joi, we should just + // remove this split logic and use `path` provided by Joi directly. + const path = rawPath ? rawPath.split('.') : []; + + const errorHandleResult = this.handleError(type, context, path); + if (errorHandleResult instanceof SchemaTypeError) { + return errorHandleResult; + } + + // If error handler just defines error message, then wrap it into proper + // `SchemaTypeError` instance. + if (typeof errorHandleResult === 'string') { + return new SchemaTypeError(errorHandleResult, path); + } + + // If error is produced by the custom validator, just extract source message + // from context and wrap it into `SchemaTypeError` instance. + if (type === 'any.custom') { + return new SchemaTypeError(context.message, path); + } + + return new SchemaTypeError(message || type, path); + } +} diff --git a/src/core/server/config/schema/types/union_type.ts b/src/core/server/config/schema/types/union_type.ts new file mode 100644 index 00000000000000..3d39ac0ea212d0 --- /dev/null +++ b/src/core/server/config/schema/types/union_type.ts @@ -0,0 +1,50 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { SchemaTypeError, SchemaTypesError } from '../errors'; +import { internals } from '../internals'; +import { AnyType } from './any_type'; +import { Type, TypeOptions } from './type'; + +export class UnionType extends Type { + constructor(types: RTS, options?: TypeOptions) { + const schema = internals.alternatives(types.map(type => type.getSchema())); + + super(schema, options); + } + + protected handleError(type: string, { reason, value }: Record, path: string[]) { + switch (type) { + case 'any.required': + return `expected at least one defined value but got [${typeDetect(value)}]`; + case 'alternatives.child': + return new SchemaTypesError( + 'types that failed validation:', + path, + reason.map((e: SchemaTypeError, index: number) => { + const childPathWithIndex = e.path.slice(); + childPathWithIndex.splice(path.length, 0, index.toString()); + + return new SchemaTypeError(e.message, childPathWithIndex); + }) + ); + } + } +} diff --git a/src/core/server/dev/dev_config.ts b/src/core/server/dev/dev_config.ts new file mode 100644 index 00000000000000..5c8aca3ce3c51c --- /dev/null +++ b/src/core/server/dev/dev_config.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema, TypeOf } from '../config/schema'; + +const createDevSchema = schema.object({ + basePathProxyTarget: schema.number({ + defaultValue: 5603, + }), +}); + +type DevConfigType = TypeOf; + +export class DevConfig { + /** + * @internal + */ + public static schema = createDevSchema; + + public basePathProxyTargetPort: number; + + /** + * @internal + */ + constructor(config: DevConfigType) { + this.basePathProxyTargetPort = config.basePathProxyTarget; + } +} diff --git a/src/core/server/dev/index.ts b/src/core/server/dev/index.ts new file mode 100644 index 00000000000000..b3fa85892330e1 --- /dev/null +++ b/src/core/server/dev/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { DevConfig } from './dev_config'; diff --git a/src/core/server/http/__tests__/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__tests__/__snapshots__/http_config.test.ts.snap new file mode 100644 index 00000000000000..4201e4f774892e --- /dev/null +++ b/src/core/server/http/__tests__/__snapshots__/http_config.test.ts.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`has defaults for config 1`] = ` +Object { + "cors": false, + "host": "localhost", + "maxPayload": ByteSizeValue { + "valueInBytes": 1048576, + }, + "port": 5601, + "rewriteBasePath": false, + "ssl": Object { + "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, + }, +} +`; + +exports[`throws if basepath appends a slash 1`] = `"[basePath]: must start with a slash, don't end with one"`; + +exports[`throws if basepath is missing prepended slash 1`] = `"[basePath]: must start with a slash, don't end with one"`; + +exports[`throws if basepath is not specified, but rewriteBasePath is set 1`] = `"cannot use [rewriteBasePath] when [basePath] is not specified"`; + +exports[`throws if invalid hostname 1`] = `"[host]: must be a valid hostname"`; + +exports[`with TLS should accept known protocols\` 1`] = ` +"[ssl.supportedProtocols.0]: types that failed validation: +- [ssl.supportedProtocols.0.0]: expected value to equal [TLSv1] but got [SOMEv100500] +- [ssl.supportedProtocols.0.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] +- [ssl.supportedProtocols.0.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" +`; + +exports[`with TLS should accept known protocols\` 2`] = ` +"[ssl.supportedProtocols.3]: types that failed validation: +- [ssl.supportedProtocols.3.0]: expected value to equal [TLSv1] but got [SOMEv100500] +- [ssl.supportedProtocols.3.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] +- [ssl.supportedProtocols.3.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" +`; + +exports[`with TLS throws if TLS is enabled but \`certificate\` is not specified 1`] = `"[ssl]: must specify [certificate] and [key] when ssl is enabled"`; + +exports[`with TLS throws if TLS is enabled but \`key\` is not specified 1`] = `"[ssl]: must specify [certificate] and [key] when ssl is enabled"`; + +exports[`with TLS throws if TLS is enabled but \`redirectHttpFromPort\` is equal to \`port\` 1`] = `"Kibana does not accept http traffic to [port] when ssl is enabled (only https is allowed), so [ssl.redirectHttpFromPort] cannot be configured to the same value. Both are [1234]."`; diff --git a/src/core/server/http/__tests__/__snapshots__/http_service.test.ts.snap b/src/core/server/http/__tests__/__snapshots__/http_service.test.ts.snap new file mode 100644 index 00000000000000..86fce993d6da2e --- /dev/null +++ b/src/core/server/http/__tests__/__snapshots__/http_service.test.ts.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`logs error if already started 1`] = ` +Object { + "debug": Array [], + "error": Array [], + "fatal": Array [], + "info": Array [], + "log": Array [], + "trace": Array [], + "warn": Array [ + Array [ + "Received new HTTP config after server was started. Config will **not** be applied.", + ], + ], +} +`; + +exports[`register route handler 1`] = ` +Object { + "debug": Array [], + "error": Array [], + "fatal": Array [], + "info": Array [ + Array [ + "registering route handler for [/foo]", + ], + ], + "log": Array [], + "trace": Array [], + "warn": Array [], +} +`; + +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.", + ], + ], + "fatal": Array [], + "info": Array [], + "log": Array [], + "trace": Array [], + "warn": Array [], +} +`; diff --git a/src/core/server/http/__tests__/__snapshots__/https_redirect_server.test.ts.snap b/src/core/server/http/__tests__/__snapshots__/https_redirect_server.test.ts.snap new file mode 100644 index 00000000000000..a4b87fdabd6c24 --- /dev/null +++ b/src/core/server/http/__tests__/__snapshots__/https_redirect_server.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws if [redirectHttpFromPort] is in use 1`] = `[Error: Redirect server cannot be started when [ssl.enabled] is set to \`false\` or [ssl.redirectHttpFromPort] is not specified.]`; + +exports[`throws if [redirectHttpFromPort] is not specified 1`] = `[Error: Redirect server cannot be started when [ssl.enabled] is set to \`false\` or [ssl.redirectHttpFromPort] is not specified.]`; + +exports[`throws if SSL is not enabled 1`] = `[Error: Redirect server cannot be started when [ssl.enabled] is set to \`false\` or [ssl.redirectHttpFromPort] is not specified.]`; diff --git a/src/core/server/http/__tests__/http_config.test.ts b/src/core/server/http/__tests__/http_config.test.ts new file mode 100644 index 00000000000000..bc21205cf8708a --- /dev/null +++ b/src/core/server/http/__tests__/http_config.test.ts @@ -0,0 +1,189 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { HttpConfig } from '../http_config'; + +test('has defaults for config', () => { + const httpSchema = HttpConfig.schema; + const obj = {}; + expect(httpSchema.validate(obj)).toMatchSnapshot(); +}); + +test('throws if invalid hostname', () => { + const httpSchema = HttpConfig.schema; + const obj = { + host: 'asdf$%^', + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); +}); + +test('can specify max payload as string', () => { + const httpSchema = HttpConfig.schema; + const obj = { + maxPayload: '2mb', + }; + const config = httpSchema.validate(obj); + expect(config.maxPayload.getValueInBytes()).toBe(2 * 1024 * 1024); +}); + +test('throws if basepath is missing prepended slash', () => { + const httpSchema = HttpConfig.schema; + const obj = { + basePath: 'foo', + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); +}); + +test('throws if basepath appends a slash', () => { + const httpSchema = HttpConfig.schema; + const obj = { + basePath: '/foo/', + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); +}); + +test('throws if basepath is not specified, but rewriteBasePath is set', () => { + const httpSchema = HttpConfig.schema; + const obj = { + rewriteBasePath: true, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); +}); + +describe('with TLS', () => { + test('throws if TLS is enabled but `key` is not specified', () => { + const httpSchema = HttpConfig.schema; + const obj = { + ssl: { + certificate: '/path/to/certificate', + enabled: true, + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); + }); + + test('throws if TLS is enabled but `certificate` is not specified', () => { + const httpSchema = HttpConfig.schema; + const obj = { + ssl: { + enabled: true, + key: '/path/to/key', + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); + }); + + test('throws if TLS is enabled but `redirectHttpFromPort` is equal to `port`', () => { + const httpSchema = HttpConfig.schema; + const obj = { + port: 1234, + ssl: { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + redirectHttpFromPort: 1234, + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); + }); + + test('can specify single `certificateAuthority` as a string', () => { + const httpSchema = HttpConfig.schema; + const obj = { + ssl: { + certificate: '/path/to/certificate', + certificateAuthorities: '/authority/', + enabled: true, + key: '/path/to/key', + }, + }; + + const config = httpSchema.validate(obj); + expect(config.ssl.certificateAuthorities).toBe('/authority/'); + }); + + test('can specify several `certificateAuthorities`', () => { + const httpSchema = HttpConfig.schema; + const obj = { + ssl: { + certificate: '/path/to/certificate', + certificateAuthorities: ['/authority/1', '/authority/2'], + enabled: true, + key: '/path/to/key', + }, + }; + + const config = httpSchema.validate(obj); + expect(config.ssl.certificateAuthorities).toEqual(['/authority/1', '/authority/2']); + }); + + test('accepts known protocols`', () => { + const httpSchema = HttpConfig.schema; + const singleKnownProtocol = { + ssl: { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['TLSv1'], + }, + }; + + const allKnownProtocols = { + ssl: { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2'], + }, + }; + + const singleKnownProtocolConfig = httpSchema.validate(singleKnownProtocol); + expect(singleKnownProtocolConfig.ssl.supportedProtocols).toEqual(['TLSv1']); + + const allKnownProtocolsConfig = httpSchema.validate(allKnownProtocols); + expect(allKnownProtocolsConfig.ssl.supportedProtocols).toEqual(['TLSv1', 'TLSv1.1', 'TLSv1.2']); + }); + + test('should accept known protocols`', () => { + const httpSchema = HttpConfig.schema; + + const singleUnknownProtocol = { + ssl: { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['SOMEv100500'], + }, + }; + + const allKnownWithOneUnknownProtocols = { + ssl: { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'SOMEv100500'], + }, + }; + + expect(() => httpSchema.validate(singleUnknownProtocol)).toThrowErrorMatchingSnapshot(); + expect(() => + httpSchema.validate(allKnownWithOneUnknownProtocols) + ).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/core/server/http/__tests__/http_server.test.ts b/src/core/server/http/__tests__/http_server.test.ts new file mode 100644 index 00000000000000..ed07d8220141bd --- /dev/null +++ b/src/core/server/http/__tests__/http_server.test.ts @@ -0,0 +1,661 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getEnvOptions } from '../../config/__tests__/__mocks__/env'; + +jest.mock('fs', () => ({ + readFileSync: jest.fn(), +})); + +import Chance from 'chance'; +import http from 'http'; +import supertest from 'supertest'; + +import { Env } from '../../config'; +import { ByteSizeValue } from '../../config/schema'; +import { logger } from '../../logging/__mocks__'; +import { HttpConfig } from '../http_config'; +import { HttpServer } from '../http_server'; +import { Router } from '../router'; + +const chance = new Chance(); + +let server: HttpServer; +let config: HttpConfig; + +function getServerListener(httpServer: HttpServer) { + return (httpServer as any).server.listener; +} + +beforeEach(() => { + config = { + host: '127.0.0.1', + maxPayload: new ByteSizeValue(1024), + port: chance.integer({ min: 10000, max: 15000 }), + ssl: {}, + } as HttpConfig; + + server = new HttpServer(logger.get(), new Env('/kibana', getEnvOptions())); +}); + +afterEach(async () => { + await server.stop(); + logger.mockClear(); +}); + +test('listening after started', async () => { + expect(server.isListening()).toBe(false); + + await server.start(config); + + expect(server.isListening()).toBe(true); +}); + +test('200 OK with body', async () => { + const router = new Router('/foo'); + + router.get({ path: '/', validate: false }, async (req, res) => { + return res.ok({ key: 'value' }); + }); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .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: '/', validate: false }, async (req, res) => { + return res.accepted({ location: 'somewhere' }); + }); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .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: '/', validate: false }, async (req, res) => { + return res.noContent(); + }); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .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: '/', validate: false }, async (req, res) => { + const err = new Error('some message'); + return res.badRequest(err); + }); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .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: schema => ({ + params: schema.object({ + test: schema.string(), + }), + }), + }, + async (req, res) => { + return res.ok({ key: req.params.test }); + } + ); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .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: schema => ({ + params: schema.object({ + test: schema.number(), + }), + }), + }, + async (req, res) => { + return res.ok({ key: req.params.test }); + } + ); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .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: schema => ({ + query: schema.object({ + bar: schema.string(), + quux: schema.number(), + }), + }), + }, + async (req, res) => { + return res.ok(req.query); + } + ); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .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: schema => ({ + query: schema.object({ + bar: schema.number(), + }), + }), + }, + async (req, res) => { + return res.ok(req.query); + } + ); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .get('/foo/?bar=test') + .expect(400) + .then(res => { + expect(res.body).toEqual({ + error: '[bar]: expected value of type [number] but got [string]', + }); + }); +}); + +test('valid body', async () => { + const router = new Router('/foo'); + + router.post( + { + path: '/', + validate: schema => ({ + body: schema.object({ + bar: schema.string(), + baz: schema.number(), + }), + }), + }, + async (req, res) => { + return res.ok(req.body); + } + ); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .post('/foo/') + .send({ + bar: 'test', + baz: 123, + }) + .expect(200) + .then(res => { + expect(res.body).toEqual({ bar: 'test', baz: 123 }); + }); +}); + +test('invalid body', async () => { + const router = new Router('/foo'); + + router.post( + { + path: '/', + validate: schema => ({ + body: schema.object({ + bar: schema.number(), + }), + }), + }, + async (req, res) => { + return res.ok(req.body); + } + ); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .post('/foo/') + .send({ bar: 'test' }) + .expect(400) + .then(res => { + expect(res.body).toEqual({ + error: '[bar]: expected value of type [number] but got [string]', + }); + }); +}); + +test('handles putting', async () => { + const router = new Router('/foo'); + + router.put( + { + path: '/', + validate: schema => ({ + body: schema.object({ + key: schema.string(), + }), + }), + }, + async (req, res) => { + return res.ok(req.body); + } + ); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .put('/foo/') + .send({ key: 'new value' }) + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'new value' }); + }); +}); + +test('handles deleting', async () => { + const router = new Router('/foo'); + + router.delete( + { + path: '/{id}', + validate: schema => ({ + params: schema.object({ + id: schema.number(), + }), + }), + }, + async (req, res) => { + return res.ok({ key: req.params.id }); + } + ); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .delete('/foo/3') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 3 }); + }); +}); + +test('filtered headers', async () => { + expect.assertions(1); + + const router = new Router('/foo'); + + let filteredHeaders: any; + + router.get({ path: '/', validate: false }, async (req, res) => { + filteredHeaders = req.getFilteredHeaders(['x-kibana-foo', 'host']); + + return res.noContent(); + }); + + server.registerRouter(router); + + await server.start(config); + + await supertest(getServerListener(server)) + .get('/foo/?bar=quux') + .set('x-kibana-foo', 'bar') + .set('x-kibana-bar', 'quux'); + + expect(filteredHeaders).toEqual({ + host: `127.0.0.1:${config.port}`, + 'x-kibana-foo': 'bar', + }); +}); + +describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { + let configWithBasePath: HttpConfig; + + beforeEach(async () => { + configWithBasePath = { + ...config, + basePath: '/bar', + rewriteBasePath: false, + } as HttpConfig; + + const router = new Router('/'); + router.get({ path: '/', validate: false }, async (req, res) => res.ok({ key: 'value:/' })); + router.get({ path: '/foo', validate: false }, async (req, res) => + res.ok({ key: 'value:/foo' }) + ); + + server.registerRouter(router); + + await server.start(configWithBasePath); + }); + + test('/bar => 404', async () => { + await supertest(getServerListener(server)) + .get('/bar') + .expect(404); + }); + + test('/bar/ => 404', async () => { + await supertest(getServerListener(server)) + .get('/bar/') + .expect(404); + }); + + test('/bar/foo => 404', async () => { + await supertest(getServerListener(server)) + .get('/bar/foo') + .expect(404); + }); + + test('/ => /', async () => { + await supertest(getServerListener(server)) + .get('/') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'value:/' }); + }); + }); + + test('/foo => /foo', async () => { + await supertest(getServerListener(server)) + .get('/foo') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'value:/foo' }); + }); + }); +}); + +describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { + let configWithBasePath: HttpConfig; + + beforeEach(async () => { + configWithBasePath = { + ...config, + basePath: '/bar', + rewriteBasePath: true, + } as HttpConfig; + + const router = new Router('/'); + router.get({ path: '/', validate: false }, async (req, res) => res.ok({ key: 'value:/' })); + router.get({ path: '/foo', validate: false }, async (req, res) => + res.ok({ key: 'value:/foo' }) + ); + + server.registerRouter(router); + + await server.start(configWithBasePath); + }); + + test('/bar => /', async () => { + await supertest(getServerListener(server)) + .get('/bar') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'value:/' }); + }); + }); + + test('/bar/ => /', async () => { + await supertest(getServerListener(server)) + .get('/bar/') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'value:/' }); + }); + }); + + test('/bar/foo => /foo', async () => { + await supertest(getServerListener(server)) + .get('/bar/foo') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'value:/foo' }); + }); + }); + + test('/ => 404', async () => { + await supertest(getServerListener(server)) + .get('/') + .expect(404); + }); + + test('/foo => 404', async () => { + await supertest(getServerListener(server)) + .get('/foo') + .expect(404); + }); +}); + +describe('with defined `redirectHttpFromPort`', () => { + let configWithSSL: HttpConfig; + + beforeEach(async () => { + configWithSSL = { + ...config, + ssl: { + certificate: '/certificate', + cipherSuites: ['cipherSuite'], + enabled: true, + getSecureOptions: () => 0, + key: '/key', + redirectHttpFromPort: config.port + 1, + }, + } as HttpConfig; + + const router = new Router('/'); + router.get({ path: '/', validate: false }, async (req, res) => res.ok({ key: 'value:/' })); + + server.registerRouter(router); + + await server.start(configWithSSL); + }); +}); + +describe('when run within legacy platform', () => { + let newPlatformProxyListenerMock: any; + beforeEach(() => { + newPlatformProxyListenerMock = { + bind: jest.fn(), + proxy: jest.fn(), + }; + + const kbnServerMock = { + newPlatformProxyListener: newPlatformProxyListenerMock, + }; + + server = new HttpServer( + logger.get(), + new Env('/kibana', getEnvOptions({ kbnServer: kbnServerMock })) + ); + + const router = new Router('/new'); + router.get({ path: '/', validate: false }, async (req, res) => { + return res.ok({ key: 'new-platform' }); + }); + + server.registerRouter(router); + + newPlatformProxyListenerMock.proxy.mockImplementation( + (req: http.IncomingMessage, res: http.ServerResponse) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ key: `legacy-platform:${req.url}` })); + } + ); + }); + + test('binds proxy listener to server.', async () => { + expect(newPlatformProxyListenerMock.bind).not.toHaveBeenCalled(); + + await server.start(config); + + expect(newPlatformProxyListenerMock.bind).toHaveBeenCalledTimes(1); + expect(newPlatformProxyListenerMock.bind).toHaveBeenCalledWith( + expect.any((http as any).Server) + ); + expect(newPlatformProxyListenerMock.bind.mock.calls[0][0]).toBe(getServerListener(server)); + }); + + test('forwards request to legacy platform if new one cannot handle it', async () => { + await server.start(config); + + await supertest(getServerListener(server)) + .get('/legacy') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'legacy-platform:/legacy' }); + expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledTimes(1); + expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledWith( + expect.any((http as any).IncomingMessage), + expect.any((http as any).ServerResponse) + ); + }); + }); + + test('forwards request to legacy platform and rewrites base path if needed', async () => { + await server.start({ + ...config, + basePath: '/bar', + rewriteBasePath: true, + }); + + await supertest(getServerListener(server)) + .get('/legacy') + .expect(404); + + await supertest(getServerListener(server)) + .get('/bar/legacy') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'legacy-platform:/legacy' }); + expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledTimes(1); + expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledWith( + expect.any((http as any).IncomingMessage), + expect.any((http as any).ServerResponse) + ); + }); + }); + + test('do not forward request to legacy platform if new one can handle it', async () => { + await server.start(config); + + await supertest(getServerListener(server)) + .get('/new/') + .expect(200) + .then(res => { + expect(res.body).toEqual({ key: 'new-platform' }); + expect(newPlatformProxyListenerMock.proxy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/core/server/http/__tests__/http_service.test.ts b/src/core/server/http/__tests__/http_service.test.ts new file mode 100644 index 00000000000000..25a5976c4a5137 --- /dev/null +++ b/src/core/server/http/__tests__/http_service.test.ts @@ -0,0 +1,173 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getEnvOptions } from '../../config/__tests__/__mocks__/env'; + +const mockHttpServer = jest.fn(); + +jest.mock('../http_server', () => ({ + HttpServer: mockHttpServer, +})); + +import { noop } from 'lodash'; +import { BehaviorSubject } from '../../../lib/kbn_observable'; + +import { Env } from '../../config'; +import { logger } from '../../logging/__mocks__'; +import { HttpConfig } from '../http_config'; +import { HttpService } from '../http_service'; +import { Router } from '../router'; + +beforeEach(() => { + logger.mockClear(); + mockHttpServer.mockClear(); +}); + +test('creates and starts http server', async () => { + const config = { + host: 'example.org', + port: 1234, + ssl: {}, + } 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, + new Env('/kibana', getEnvOptions()) + ); + + expect(mockHttpServer.mock.instances.length).toBe(1); + expect(httpServer.start).not.toHaveBeenCalled(); + + await service.start(); + + expect(httpServer.start).toHaveBeenCalledTimes(1); +}); + +test('logs error if already started', async () => { + const config = { ssl: {} } 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, + new Env('/kibana', getEnvOptions()) + ); + + await service.start(); + + expect(logger.mockCollect()).toMatchSnapshot(); +}); + +test('stops http server', async () => { + const config = { ssl: {} } 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, + new Env('/kibana', getEnvOptions()) + ); + + await service.start(); + + expect(httpServer.stop).toHaveBeenCalledTimes(0); + + await service.stop(); + + expect(httpServer.stop).toHaveBeenCalledTimes(1); +}); + +test('register route handler', () => { + const config = {} as HttpConfig; + + const config$ = new BehaviorSubject(config); + + const httpServer = { + isListening: () => false, + registerRouter: jest.fn(), + start: noop, + stop: noop, + }; + mockHttpServer.mockImplementation(() => httpServer); + + const service = new HttpService( + config$.asObservable(), + logger, + new Env('/kibana', getEnvOptions()) + ); + + const router = new Router('/foo'); + service.registerRouter(router); + + expect(httpServer.registerRouter).toHaveBeenCalledTimes(1); + expect(httpServer.registerRouter).toHaveBeenLastCalledWith(router); + expect(logger.mockCollect()).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, + registerRouter: jest.fn(), + start: noop, + stop: noop, + }; + mockHttpServer.mockImplementation(() => httpServer); + + const service = new HttpService( + config$.asObservable(), + logger, + new Env('/kibana', getEnvOptions()) + ); + + const router = new Router('/foo'); + service.registerRouter(router); + + expect(httpServer.registerRouter).toHaveBeenCalledTimes(0); + expect(logger.mockCollect()).toMatchSnapshot(); +}); diff --git a/src/core/server/http/__tests__/https_redirect_server.test.ts b/src/core/server/http/__tests__/https_redirect_server.test.ts new file mode 100644 index 00000000000000..c92691a679ef06 --- /dev/null +++ b/src/core/server/http/__tests__/https_redirect_server.test.ts @@ -0,0 +1,107 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('fs', () => ({ + readFileSync: jest.fn(), +})); + +import Chance from 'chance'; +import { Server } from 'http'; +import supertest from 'supertest'; + +import { ByteSizeValue } from '../../config/schema'; +import { logger } from '../../logging/__mocks__'; +import { HttpConfig } from '../http_config'; +import { HttpsRedirectServer } from '../https_redirect_server'; + +const chance = new Chance(); + +let server: HttpsRedirectServer; +let config: HttpConfig; + +function getServerListener(httpServer: HttpsRedirectServer) { + return (httpServer as any).server.listener; +} + +beforeEach(() => { + config = { + host: '127.0.0.1', + maxPayload: new ByteSizeValue(1024), + port: chance.integer({ min: 10000, max: 15000 }), + ssl: { + enabled: true, + redirectHttpFromPort: chance.integer({ min: 20000, max: 30000 }), + }, + } as HttpConfig; + + server = new HttpsRedirectServer(logger.get()); +}); + +afterEach(async () => { + await server.stop(); + logger.mockClear(); +}); + +test('throws if SSL is not enabled', async () => { + await expect( + server.start({ + ...config, + ssl: { + enabled: false, + redirectHttpFromPort: chance.integer({ min: 20000, max: 30000 }), + }, + } as HttpConfig) + ).rejects.toMatchSnapshot(); +}); + +test('throws if [redirectHttpFromPort] is not specified', async () => { + await expect( + server.start({ + ...config, + ssl: { enabled: true }, + } as HttpConfig) + ).rejects.toMatchSnapshot(); +}); + +test('throws if [redirectHttpFromPort] is in use', async () => { + const mockListen = jest.spyOn(Server.prototype, 'listen').mockImplementation(() => { + throw { code: 'EADDRINUSE' }; + }); + + await expect( + server.start({ + ...config, + ssl: { enabled: true }, + } as HttpConfig) + ).rejects.toMatchSnapshot(); + + // Workaround for https://github.com/DefinitelyTyped/DefinitelyTyped/issues/17605. + (mockListen as any).mockRestore(); +}); + +test('forwards http requests to https', async () => { + await server.start(config); + + await supertest(getServerListener(server)) + .get('/') + .expect(302) + .then(res => { + expect(res.header.location).toEqual(`https://${config.host}:${config.port}/`); + }); +}); diff --git a/src/core/server/http/base_path_proxy_server.ts b/src/core/server/http/base_path_proxy_server.ts new file mode 100644 index 00000000000000..f4a9b59b77b10d --- /dev/null +++ b/src/core/server/http/base_path_proxy_server.ts @@ -0,0 +1,162 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Server } from 'hapi-latest'; +import { Agent as HttpsAgent, ServerOptions as TlsOptions } from 'https'; +import { sample } from 'lodash'; +import { ByteSizeValue } from '../config/schema'; +import { DevConfig } from '../dev'; +import { Logger } from '../logging'; +import { HttpConfig } from './http_config'; +import { createServer, getServerOptions } from './http_tools'; + +const alphabet = 'abcdefghijklmnopqrztuvwxyz'.split(''); + +export interface BasePathProxyServerOptions { + httpConfig: HttpConfig; + devConfig: DevConfig; + shouldRedirectFromOldBasePath: (path: string) => boolean; + blockUntil: () => Promise; +} + +export class BasePathProxyServer { + private server?: Server; + private httpsAgent?: HttpsAgent; + + get basePath() { + return this.options.httpConfig.basePath; + } + + get targetPort() { + return this.options.devConfig.basePathProxyTargetPort; + } + + constructor(private readonly log: Logger, private readonly options: BasePathProxyServerOptions) { + const ONE_GIGABYTE = 1024 * 1024 * 1024; + options.httpConfig.maxPayload = new ByteSizeValue(ONE_GIGABYTE); + + if (!options.httpConfig.basePath) { + options.httpConfig.basePath = `/${sample(alphabet, 3).join('')}`; + } + } + + public async start() { + const { httpConfig } = this.options; + + const options = getServerOptions(httpConfig); + this.server = createServer(options); + + // Register hapi plugin that adds proxying functionality. It can be configured + // through the route configuration object (see { handler: { proxy: ... } }). + await this.server.register({ plugin: require('h2o2-latest') }); + + if (httpConfig.ssl.enabled) { + const tlsOptions = options.tls as TlsOptions; + this.httpsAgent = new HttpsAgent({ + ca: tlsOptions.ca, + cert: tlsOptions.cert, + key: tlsOptions.key, + passphrase: tlsOptions.passphrase, + rejectUnauthorized: false, + }); + } + + this.setupRoutes(); + + this.log.info( + `starting basepath proxy server at ${this.server.info.uri}${httpConfig.basePath}` + ); + + await this.server.start(); + } + + public async stop() { + this.log.info('stopping basepath proxy server'); + + if (this.server !== undefined) { + await this.server.stop(); + this.server = undefined; + } + + if (this.httpsAgent !== undefined) { + this.httpsAgent.destroy(); + this.httpsAgent = undefined; + } + } + + private setupRoutes() { + if (this.server === undefined) { + throw new Error(`Routes cannot be set up since server is not initialized.`); + } + + const { httpConfig, devConfig, blockUntil, shouldRedirectFromOldBasePath } = this.options; + + // Always redirect from root URL to the URL with basepath. + this.server.route({ + handler: (request, responseToolkit) => { + return responseToolkit.redirect(httpConfig.basePath); + }, + method: 'GET', + path: '/', + }); + + this.server.route({ + handler: { + proxy: { + agent: this.httpsAgent, + host: this.server.info.host, + passThrough: true, + port: devConfig.basePathProxyTargetPort, + protocol: this.server.info.protocol, + xforward: true, + }, + }, + method: '*', + options: { + pre: [ + // Before we proxy request to a target port we may want to wait until some + // condition is met (e.g. until target listener is ready). + async (request, responseToolkit) => { + await blockUntil(); + return responseToolkit.continue; + }, + ], + }, + path: `${httpConfig.basePath}/{kbnPath*}`, + }); + + // It may happen that basepath has changed, but user still uses the old one, + // so we can try to check if that's the case and just redirect user to the + // same URL, but with valid basepath. + this.server.route({ + handler: (request, responseToolkit) => { + const { oldBasePath, kbnPath = '' } = request.params; + + const isGet = request.method === 'get'; + const isBasepathLike = oldBasePath.length === 3; + + return isGet && isBasepathLike && shouldRedirectFromOldBasePath(kbnPath) + ? responseToolkit.redirect(`${httpConfig.basePath}/${kbnPath}`) + : responseToolkit.response('Not Found').code(404); + }, + method: '*', + path: `/{oldBasePath}/{kbnPath*}`, + }); + } +} diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts new file mode 100644 index 00000000000000..bef8baf7941476 --- /dev/null +++ b/src/core/server/http/http_config.ts @@ -0,0 +1,116 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Env } from '../config'; +import { ByteSizeValue, schema, TypeOf } from '../config/schema'; +import { SslConfig } from './ssl_config'; + +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.object( + { + basePath: schema.maybe( + schema.string({ + validate: match(validBasePathRegex, "must start with a slash, don't end with one"), + }) + ), + cors: schema.conditional( + schema.contextRef('dev'), + true, + schema.object( + { + origin: schema.arrayOf(schema.string()), + }, + { + defaultValue: { + origin: ['*://localhost:9876'], // karma test server + }, + } + ), + schema.boolean({ defaultValue: false }) + ), + host: schema.string({ + defaultValue: 'localhost', + validate: match(validHostnameRegex, 'must be a valid hostname'), + }), + maxPayload: schema.byteSize({ + defaultValue: '1048576b', + }), + port: schema.number({ + defaultValue: 5601, + }), + rewriteBasePath: schema.boolean({ defaultValue: false }), + ssl: SslConfig.schema, + }, + { + validate: config => { + if (!config.basePath && config.rewriteBasePath) { + return 'cannot use [rewriteBasePath] when [basePath] is not specified'; + } + + if ( + config.ssl.enabled && + config.ssl.redirectHttpFromPort !== undefined && + config.ssl.redirectHttpFromPort === config.port + ) { + return ( + 'Kibana does not accept http traffic to [port] when ssl is ' + + 'enabled (only https is allowed), so [ssl.redirectHttpFromPort] ' + + `cannot be configured to the same value. Both are [${config.port}].` + ); + } + }, + } +); + +type HttpConfigType = TypeOf; + +export class HttpConfig { + /** + * @internal + */ + public static schema = createHttpSchema; + + public host: string; + public port: number; + public cors: boolean | { origin: string[] }; + public maxPayload: ByteSizeValue; + public basePath?: string; + public rewriteBasePath: boolean; + public publicDir: string; + public ssl: SslConfig; + + /** + * @internal + */ + constructor(config: HttpConfigType, env: Env) { + this.host = config.host; + this.port = config.port; + this.cors = config.cors; + this.maxPayload = config.maxPayload; + this.basePath = config.basePath; + this.rewriteBasePath = config.rewriteBasePath; + this.publicDir = env.staticFilesDir; + this.ssl = new SslConfig(config.ssl); + } +} diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts new file mode 100644 index 00000000000000..7d51763968f96b --- /dev/null +++ b/src/core/server/http/http_server.ts @@ -0,0 +1,137 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Server } from 'hapi-latest'; + +import { modifyUrl } from '../../utils'; +import { Env } from '../config'; +import { Logger } from '../logging'; +import { HttpConfig } from './http_config'; +import { createServer, getServerOptions } from './http_tools'; +import { Router } from './router'; + +export class HttpServer { + private server?: Server; + private registeredRouters: Set = new Set(); + + constructor(private readonly log: Logger, private readonly env: Env) {} + + public isListening() { + return this.server !== undefined && this.server.listener.listening; + } + + public registerRouter(router: Router) { + if (this.isListening()) { + throw new Error('Routers can be registered only when HTTP server is stopped.'); + } + + this.registeredRouters.add(router); + } + + public async start(config: HttpConfig) { + this.server = createServer(getServerOptions(config)); + + this.setupBasePathRewrite(this.server, config); + + for (const router of this.registeredRouters) { + for (const route of router.getRoutes()) { + this.server.route({ + handler: route.handler, + method: route.method, + path: this.getRouteFullPath(router.path, route.path), + }); + } + } + + const legacyKbnServer = this.env.getLegacyKbnServer(); + if (legacyKbnServer !== undefined) { + legacyKbnServer.newPlatformProxyListener.bind(this.server.listener); + + // We register Kibana proxy middleware right before we start server to allow + // all new platform plugins register their routes, so that `legacyKbnServer` + // handles only requests that aren't handled by the new platform. + this.server.route({ + handler: ({ raw: { req, res } }, responseToolkit) => { + legacyKbnServer.newPlatformProxyListener.proxy(req, res); + return responseToolkit.abandon; + }, + method: '*', + options: { + payload: { + output: 'stream', + parse: false, + timeout: false, + }, + }, + path: '/{p*}', + }); + } + + this.log.info(`starting http server [${config.host}:${config.port}]`); + + await this.server.start(); + } + + public async stop() { + this.log.info('stopping http server'); + + if (this.server !== undefined) { + await this.server.stop(); + this.server = undefined; + } + } + + private setupBasePathRewrite(server: Server, config: HttpConfig) { + if (config.basePath === undefined || !config.rewriteBasePath) { + return; + } + + const basePath = config.basePath; + server.ext('onRequest', (request, responseToolkit) => { + const newURL = modifyUrl(request.url.href!, urlParts => { + if (urlParts.pathname != null && urlParts.pathname.startsWith(basePath)) { + urlParts.pathname = urlParts.pathname.replace(basePath, '') || '/'; + } else { + return {}; + } + }); + + if (!newURL) { + return responseToolkit + .response('Not Found') + .code(404) + .takeover(); + } + + request.setUrl(newURL); + // We should update raw request as well since it can be proxied to the old platform + // where base path isn't expected. + request.raw.req.url = request.url.href; + + return responseToolkit.continue; + }); + } + + private getRouteFullPath(routerPath: string, routePath: string) { + // If router's path ends with slash and route's path starts with slash, + // we should omit one of them to have a valid concatenated path. + const routePathStartIndex = routerPath.endsWith('/') && routePath.startsWith('/') ? 1 : 0; + return `${routerPath}${routePath.slice(routePathStartIndex)}`; + } +} diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts new file mode 100644 index 00000000000000..acede95f9c9951 --- /dev/null +++ b/src/core/server/http/http_service.ts @@ -0,0 +1,92 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { first, k$, Observable, Subscription, toPromise } from '../../lib/kbn_observable'; + +import { CoreService } from '../../types/core_service'; +import { Env } from '../config'; +import { Logger, LoggerFactory } from '../logging'; +import { HttpConfig } from './http_config'; +import { HttpServer } from './http_server'; +import { HttpsRedirectServer } from './https_redirect_server'; +import { Router } from './router'; + +export class HttpService implements CoreService { + private readonly httpServer: HttpServer; + private readonly httpsRedirectServer: HttpsRedirectServer; + private configSubscription?: Subscription; + + private readonly log: Logger; + + constructor(private readonly config$: Observable, logger: LoggerFactory, env: Env) { + this.log = logger.get('http'); + + this.httpServer = new HttpServer(logger.get('http', 'server'), env); + this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); + } + + public async start() { + this.configSubscription = this.config$.subscribe(() => { + 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.warn( + 'Received new HTTP config after server was started. ' + 'Config will **not** be applied.' + ); + } + }); + + const config = await k$(this.config$)(first(), toPromise()); + + // If a redirect port is specified, we start an HTTP server at this port and + // redirect all requests to the SSL port. + if (config.ssl.enabled && config.ssl.redirectHttpFromPort !== undefined) { + await this.httpsRedirectServer.start(config); + } + + await this.httpServer.start(config); + } + + public async stop() { + if (this.configSubscription === undefined) { + return; + } + + this.configSubscription.unsubscribe(); + this.configSubscription = undefined; + + await this.httpServer.stop(); + await this.httpsRedirectServer.stop(); + } + + public 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); + } + } +} diff --git a/src/core/server/http/http_tools.ts b/src/core/server/http/http_tools.ts new file mode 100644 index 00000000000000..4f88588a552a38 --- /dev/null +++ b/src/core/server/http/http_tools.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFileSync } from 'fs'; +import { Server, ServerOptions } from 'hapi-latest'; +import { ServerOptions as TLSOptions } from 'https'; +import { HttpConfig } from './http_config'; + +/** + * Converts Kibana `HttpConfig` into `ServerOptions` that are accepted by the Hapi server. + */ +export function getServerOptions(config: HttpConfig, { configureTLS = true } = {}) { + // Note that all connection options configured here should be exactly the same + // as in the legacy platform server (see `src/server/http/index`). Any change + // SHOULD BE applied in both places. The only exception is TLS-specific options, + // that are configured only here. + const options: ServerOptions = { + host: config.host, + port: config.port, + routes: { + cors: config.cors, + payload: { + maxBytes: config.maxPayload.getValueInBytes(), + }, + validate: { + options: { + abortEarly: false, + }, + }, + }, + state: { + strictHeader: false, + }, + }; + + if (configureTLS && config.ssl.enabled) { + const ssl = config.ssl; + + // TODO: Hapi types have a typo in `tls` property type definition: `https.RequestOptions` is used instead of + // `https.ServerOptions`, and `honorCipherOrder` isn't presented in `https.RequestOptions`. + const tlsOptions: TLSOptions = { + ca: + config.ssl.certificateAuthorities && + config.ssl.certificateAuthorities.map(caFilePath => readFileSync(caFilePath)), + cert: readFileSync(ssl.certificate!), + ciphers: config.ssl.cipherSuites.join(':'), + // We use the server's cipher order rather than the client's to prevent the BEAST attack. + honorCipherOrder: true, + key: readFileSync(ssl.key!), + passphrase: ssl.keyPassphrase, + secureOptions: ssl.getSecureOptions(), + }; + + options.tls = tlsOptions; + } + + return options; +} + +export function createServer(options: ServerOptions) { + const server = new Server(options); + + // Revert to previous 120 seconds keep-alive timeout in Node < 8. + server.listener.keepAliveTimeout = 120e3; + server.listener.on('clientError', (err, socket) => { + if (socket.writable) { + socket.end(new Buffer('HTTP/1.1 400 Bad Request\r\n\r\n', 'ascii')); + } else { + socket.destroy(err); + } + }); + + return server; +} diff --git a/src/core/server/http/https_redirect_server.ts b/src/core/server/http/https_redirect_server.ts new file mode 100644 index 00000000000000..9a77c63f1b85b1 --- /dev/null +++ b/src/core/server/http/https_redirect_server.ts @@ -0,0 +1,89 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Request, ResponseToolkit, Server } from 'hapi-latest'; +import { format as formatUrl } from 'url'; + +import { Logger } from '../logging'; +import { HttpConfig } from './http_config'; +import { createServer, getServerOptions } from './http_tools'; + +export class HttpsRedirectServer { + private server?: Server; + + constructor(private readonly log: Logger) {} + + public async start(config: HttpConfig) { + if (!config.ssl.enabled || config.ssl.redirectHttpFromPort === undefined) { + throw new Error( + 'Redirect server cannot be started when [ssl.enabled] is set to `false`' + + ' or [ssl.redirectHttpFromPort] is not specified.' + ); + } + + this.log.info( + `starting HTTP --> HTTPS redirect server [${config.host}:${config.ssl.redirectHttpFromPort}]` + ); + + // Redirect server is configured in the same way as any other HTTP server + // within the platform with the only exception that it should always be a + // plain HTTP server, so we just ignore `tls` part of options. + this.server = createServer({ + ...getServerOptions(config, { configureTLS: false }), + port: config.ssl.redirectHttpFromPort, + }); + + this.server.ext('onRequest', (request: Request, responseToolkit: ResponseToolkit) => { + return responseToolkit + .redirect( + formatUrl({ + hostname: config.host, + pathname: request.url.pathname, + port: config.port, + protocol: 'https', + search: request.url.search, + }) + ) + .takeover(); + }); + + try { + await this.server.start(); + } catch (err) { + if (err.code === 'EADDRINUSE') { + throw new Error( + 'The redirect server failed to start up because port ' + + `${config.ssl.redirectHttpFromPort} is already in use. Ensure the port specified ` + + 'in `server.ssl.redirectHttpFromPort` is available.' + ); + } else { + throw err; + } + } + } + + public async stop() { + this.log.info('stopping HTTPS redirect server'); + + if (this.server !== undefined) { + await this.server.stop(); + this.server = undefined; + } + } +} diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts new file mode 100644 index 00000000000000..5ddab84b672137 --- /dev/null +++ b/src/core/server/http/index.ts @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from '../../lib/kbn_observable'; + +import { Env } from '../config'; +import { LoggerFactory } from '../logging'; +import { HttpConfig } from './http_config'; +import { HttpService } from './http_service'; + +export { Router, KibanaRequest } from './router'; +export { HttpService }; + +export { HttpConfig }; + +export class HttpModule { + public readonly service: HttpService; + + constructor(readonly config$: Observable, logger: LoggerFactory, env: Env) { + this.service = new HttpService(this.config$, logger, env); + } +} diff --git a/src/core/server/http/router/headers.ts b/src/core/server/http/router/headers.ts new file mode 100644 index 00000000000000..d5a3de1ff452d8 --- /dev/null +++ b/src/core/server/http/router/headers.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { pick } from '../../../utils'; + +export interface Headers { + [key: string]: string | string[] | undefined; +} + +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); +} diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts new file mode 100644 index 00000000000000..ceec1d4c42649e --- /dev/null +++ b/src/core/server/http/router/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { Router } from './router'; +export { KibanaRequest } from './request'; diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts new file mode 100644 index 00000000000000..87f8e5d8661589 --- /dev/null +++ b/src/core/server/http/router/request.ts @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Request } from 'hapi-latest'; +import { ObjectType, TypeOf } from '../../config/schema'; + +import { filterHeaders, Headers } from './headers'; +import { RouteSchemas } from './route'; + +export class KibanaRequest { + /** + * Factory for creating requests. Validates the request before creating an + * instance of a KibanaRequest. + */ + public static from

( + req: Request, + routeSchemas: RouteSchemas | undefined + ) { + const requestParts = KibanaRequest.validate(req, routeSchemas); + return new KibanaRequest(req, requestParts.params, requestParts.query, requestParts.body); + } + + /** + * Validates the different parts of a request based on the schemas defined for + * the route. Builds up the actual params, query and body object that will be + * received in the route handler. + */ + private static validate

( + req: Request, + routeSchemas: RouteSchemas | undefined + ): { + params: TypeOf

; + query: TypeOf; + body: TypeOf; + } { + if (routeSchemas === undefined) { + return { + body: {}, + params: {}, + query: {}, + }; + } + + const params = + routeSchemas.params === undefined ? {} : routeSchemas.params.validate(req.params); + + const query = routeSchemas.query === undefined ? {} : routeSchemas.query.validate(req.query); + + const body = routeSchemas.body === undefined ? {} : routeSchemas.body.validate(req.payload); + + return { query, params, body }; + } + + public readonly headers: Headers; + + constructor(req: Request, readonly params: Params, readonly query: Query, readonly body: Body) { + this.headers = req.headers; + } + + public getFilteredHeaders(headersToKeep: string[]) { + return filterHeaders(this.headers, headersToKeep); + } +} diff --git a/src/core/server/http/router/response.ts b/src/core/server/http/router/response.ts new file mode 100644 index 00000000000000..6e767aea0033de --- /dev/null +++ b/src/core/server/http/router/response.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// TODO Needs _some_ work +export type StatusCode = 200 | 202 | 204 | 400; + +export class KibanaResponse { + constructor(readonly status: StatusCode, readonly payload?: T) {} +} + +export const responseFactory = { + accepted: (payload: T) => new KibanaResponse(202, payload), + badRequest: (err: T) => new KibanaResponse(400, err), + noContent: () => new KibanaResponse(204), + ok: (payload: T) => new KibanaResponse(200, payload), +}; + +export type ResponseFactory = typeof responseFactory; diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts new file mode 100644 index 00000000000000..a8647815c4ed1d --- /dev/null +++ b/src/core/server/http/router/route.ts @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ObjectType, Schema } from '../../config/schema'; +export type RouteMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +export interface RouteConfig

{ + /** + * The endpoint _within_ the router path to register the route. E.g. if the + * router is registered at `/elasticsearch` and the route path is `/search`, + * the full path for the route is `/elasticsearch/search`. + */ + path: string; + + /** + * A function that will be called when setting up the route and that returns + * a schema that every request will be validated against. + * + * To opt out of validating the request, specify `false`. + */ + validate: RouteValidateFactory | false; +} + +export type RouteValidateFactory< + P extends ObjectType, + Q extends ObjectType, + B extends ObjectType +> = (schema: Schema) => RouteSchemas; + +/** + * RouteSchemas contains the schemas for validating the different parts of a + * request. + */ +export interface RouteSchemas

{ + params?: P; + query?: Q; + body?: B; +} diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts new file mode 100644 index 00000000000000..89296e1f061251 --- /dev/null +++ b/src/core/server/http/router/router.ts @@ -0,0 +1,173 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Request, ResponseObject, ResponseToolkit } from 'hapi-latest'; +import { ObjectType, schema, TypeOf } from '../../config/schema'; + +import { KibanaRequest } from './request'; +import { KibanaResponse, ResponseFactory, responseFactory } from './response'; +import { RouteConfig, RouteMethod, RouteSchemas } from './route'; + +export interface RouterRoute { + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + handler: (req: Request, responseToolkit: ResponseToolkit) => Promise; +} + +export class Router { + public routes: Array> = []; + + constructor(readonly path: string) {} + + /** + * Register a `GET` request with the router + */ + public get

( + route: RouteConfig, + handler: RequestHandler + ) { + const routeSchemas = this.routeSchemasFromRouteConfig(route, 'GET'); + this.routes.push({ + handler: async (req, responseToolkit) => + await this.handle(routeSchemas, req, responseToolkit, handler), + method: 'GET', + path: route.path, + }); + } + + /** + * Register a `POST` request with the router + */ + public post

( + route: RouteConfig, + handler: RequestHandler + ) { + const routeSchemas = this.routeSchemasFromRouteConfig(route, 'POST'); + this.routes.push({ + handler: async (req, responseToolkit) => + await this.handle(routeSchemas, req, responseToolkit, handler), + method: 'POST', + path: route.path, + }); + } + + /** + * Register a `PUT` request with the router + */ + public put

( + route: RouteConfig, + handler: RequestHandler + ) { + const routeSchemas = this.routeSchemasFromRouteConfig(route, 'POST'); + this.routes.push({ + handler: async (req, responseToolkit) => + await this.handle(routeSchemas, req, responseToolkit, handler), + method: 'PUT', + path: route.path, + }); + } + + /** + * Register a `DELETE` request with the router + */ + public delete

( + route: RouteConfig, + handler: RequestHandler + ) { + const routeSchemas = this.routeSchemasFromRouteConfig(route, 'DELETE'); + this.routes.push({ + handler: async (req, responseToolkit) => + await this.handle(routeSchemas, req, responseToolkit, handler), + method: 'DELETE', + path: route.path, + }); + } + + /** + * Returns all routes registered with the this router. + * @returns List of registered routes. + */ + public getRoutes() { + return [...this.routes]; + } + + /** + * Create the schemas for a route + * + * @returns Route schemas if `validate` is specified on the route, otherwise + * undefined. + */ + private routeSchemasFromRouteConfig< + P extends ObjectType, + Q extends ObjectType, + B extends ObjectType + >(route: RouteConfig, routeMethod: RouteMethod) { + // The type doesn't allow `validate` to be undefined, but it can still + // happen when it's used from JavaScript. + if (route.validate === undefined) { + throw new Error( + `The [${routeMethod}] at [${ + route.path + }] does not have a 'validate' specified. Use 'false' as the value if you want to bypass validation.` + ); + } + + return route.validate ? route.validate(schema) : undefined; + } + + private async handle

( + routeSchemas: RouteSchemas | undefined, + request: Request, + responseToolkit: ResponseToolkit, + handler: RequestHandler + ) { + let kibanaRequest: KibanaRequest, TypeOf, TypeOf>; + + try { + kibanaRequest = KibanaRequest.from(request, routeSchemas); + } catch (e) { + // TODO Handle failed validation + return responseToolkit.response({ error: e.message }).code(400); + } + + try { + const kibanaResponse = await handler(kibanaRequest, responseFactory); + + let payload = null; + if (kibanaResponse.payload instanceof Error) { + // TODO Design an error format + payload = { error: kibanaResponse.payload.message }; + } else if (kibanaResponse.payload !== undefined) { + payload = kibanaResponse.payload; + } + + return responseToolkit.response(payload).code(kibanaResponse.status); + } catch (e) { + // TODO Handle `KibanaResponseError` + + // Otherwise we default to something along the lines of + return responseToolkit.response({ error: e.message }).code(500); + } + } +} + +export type RequestHandler

= ( + req: KibanaRequest, TypeOf, TypeOf>, + createResponse: ResponseFactory +) => Promise>; diff --git a/src/core/server/http/ssl_config.ts b/src/core/server/http/ssl_config.ts new file mode 100644 index 00000000000000..f7be7ab05d410a --- /dev/null +++ b/src/core/server/http/ssl_config.ts @@ -0,0 +1,125 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import crypto from 'crypto'; +import { schema, TypeOf } from '../config/schema'; + +// `crypto` type definitions doesn't currently include `crypto.constants`, see +// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/fa5baf1733f49cf26228a4e509914572c1b74adf/types/node/v6/index.d.ts#L3412 +const cryptoConstants = (crypto as any).constants; + +const protocolMap = new Map([ + ['TLSv1', cryptoConstants.SSL_OP_NO_TLSv1], + ['TLSv1.1', cryptoConstants.SSL_OP_NO_TLSv1_1], + ['TLSv1.2', cryptoConstants.SSL_OP_NO_TLSv1_2], +]); + +const sslSchema = schema.object( + { + certificate: schema.maybe(schema.string()), + certificateAuthorities: schema.maybe( + schema.oneOf([schema.arrayOf(schema.string()), schema.string()]) + ), + cipherSuites: schema.arrayOf(schema.string(), { + defaultValue: cryptoConstants.defaultCoreCipherList.split(':'), + }), + enabled: schema.boolean({ + defaultValue: false, + }), + key: schema.maybe(schema.string()), + keyPassphrase: schema.maybe(schema.string()), + redirectHttpFromPort: schema.maybe(schema.number()), + supportedProtocols: schema.maybe( + schema.arrayOf( + schema.oneOf([ + schema.literal('TLSv1'), + schema.literal('TLSv1.1'), + schema.literal('TLSv1.2'), + ]) + ) + ), + }, + { + validate: ssl => { + if (ssl.enabled && (!ssl.key || !ssl.certificate)) { + return 'must specify [certificate] and [key] when ssl is enabled'; + } + }, + } +); + +type SslConfigType = TypeOf; + +export class SslConfig { + /** + * @internal + */ + public static schema = sslSchema; + + public enabled: boolean; + public redirectHttpFromPort: number | undefined; + public key: string | undefined; + public certificate: string | undefined; + public certificateAuthorities: string[] | undefined; + public keyPassphrase: string | undefined; + + public cipherSuites: string[]; + public supportedProtocols: string[] | undefined; + + /** + * @internal + */ + constructor(config: SslConfigType) { + this.enabled = config.enabled; + this.redirectHttpFromPort = config.redirectHttpFromPort; + this.key = config.key; + this.certificate = config.certificate; + this.certificateAuthorities = this.initCertificateAuthorities(config.certificateAuthorities); + this.keyPassphrase = config.keyPassphrase; + this.cipherSuites = config.cipherSuites; + this.supportedProtocols = config.supportedProtocols; + } + + /** + * Options that affect the OpenSSL protocol behavior via numeric bitmask of the SSL_OP_* options from OpenSSL Options. + */ + public getSecureOptions() { + if (this.supportedProtocols === undefined || this.supportedProtocols.length === 0) { + return 0; + } + + const supportedProtocols = this.supportedProtocols; + return Array.from(protocolMap).reduce((secureOptions, [protocolAlias, secureOption]) => { + // `secureOption` is the option that turns *off* support for a particular protocol, + // so if protocol is supported, we should not enable this option. + // tslint:disable no-bitwise + return supportedProtocols.includes(protocolAlias) + ? secureOptions + : secureOptions | secureOption; + }, 0); + } + + private initCertificateAuthorities(certificateAuthorities?: string[] | string) { + if (certificateAuthorities === undefined || Array.isArray(certificateAuthorities)) { + return certificateAuthorities; + } + + return [certificateAuthorities]; + } +} diff --git a/src/core/server/index.ts b/src/core/server/index.ts new file mode 100644 index 00000000000000..4ef90de03cebfc --- /dev/null +++ b/src/core/server/index.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ConfigService, Env } from './config'; +import { HttpConfig, HttpModule, Router } from './http'; +import { Logger, LoggerFactory } from './logging'; + +export class Server { + private readonly http: HttpModule; + private readonly log: Logger; + + constructor(private readonly configService: ConfigService, logger: LoggerFactory, env: Env) { + this.log = logger.get('server'); + + const httpConfig$ = configService.atPath('server', HttpConfig); + this.http = new HttpModule(httpConfig$, logger, env); + } + + public async start() { + this.log.info('starting server :tada:'); + + const router = new Router('/core'); + router.get({ path: '/', validate: false }, async (req, res) => res.ok({ version: '0.0.1' })); + this.http.service.registerRouter(router); + + 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)}`); + } + } + + public async stop() { + this.log.debug('stopping server'); + + await this.http.service.stop(); + } +} diff --git a/src/core/server/legacy_compat/__mocks__/legacy_config_mock.ts b/src/core/server/legacy_compat/__mocks__/legacy_config_mock.ts new file mode 100644 index 00000000000000..ed1b7c04625756 --- /dev/null +++ b/src/core/server/legacy_compat/__mocks__/legacy_config_mock.ts @@ -0,0 +1,45 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * This is a partial mock of src/server/config/config.js. + */ +export class LegacyConfigMock { + public readonly set = jest.fn((key, value) => { + // Real legacy config throws error if key is not presented in the schema. + if (!this.rawData.has(key)) { + throw new TypeError(`Unknown schema key: ${key}`); + } + + this.rawData.set(key, value); + }); + + public readonly get = jest.fn(key => { + // Real legacy config throws error if key is not presented in the schema. + if (!this.rawData.has(key)) { + throw new TypeError(`Unknown schema key: ${key}`); + } + + return this.rawData.get(key); + }); + + public readonly has = jest.fn(key => this.rawData.has(key)); + + constructor(public rawData: Map = new Map()) {} +} diff --git a/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_kbn_server.test.ts.snap b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_kbn_server.test.ts.snap new file mode 100644 index 00000000000000..af1b7cf4c6e76a --- /dev/null +++ b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_kbn_server.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`correctly forwards log record. 1`] = ` +Array [ + Array [ + Array [ + "one", + "two", + ], + "message", + 2012-02-01T11:22:33.044Z, + ], + Array [ + "three", + [Error: log error], + 2012-02-01T11:22:33.044Z, + ], +] +`; diff --git a/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_config.test.ts.snap b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_config.test.ts.snap new file mode 100644 index 00000000000000..af2ed3c1491b60 --- /dev/null +++ b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_config.test.ts.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#get correctly handles paths that do not exist in legacy config. 1`] = `"Unknown schema key: one"`; + +exports[`#get correctly handles paths that do not exist in legacy config. 2`] = `"Unknown schema key: one.two"`; + +exports[`#get correctly handles paths that do not exist in legacy config. 3`] = `"Unknown schema key: one.three"`; + +exports[`#set correctly sets values for new platform config. 1`] = ` +Object { + "plugins": Object { + "scanDirs": Array [ + "bar", + ], + }, +} +`; + +exports[`#set correctly sets values for new platform config. 2`] = ` +Object { + "plugins": Object { + "scanDirs": Array [ + "baz", + ], + }, +} +`; + +exports[`#set tries to set values for paths that do not exist in legacy config. 1`] = `"Unknown schema key: unknown"`; + +exports[`#set tries to set values for paths that do not exist in legacy config. 2`] = `"Unknown schema key: unknown.sub1"`; + +exports[`#set tries to set values for paths that do not exist in legacy config. 3`] = `"Unknown schema key: unknown.sub2"`; + +exports[`\`getFlattenedPaths\` returns paths from new platform config only. 1`] = ` +Array [ + "__newPlatform.known", + "__newPlatform.known2.sub", +] +`; diff --git a/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_proxifier.test.ts.snap b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_proxifier.test.ts.snap new file mode 100644 index 00000000000000..41d10685923dfb --- /dev/null +++ b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_proxifier.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`correctly unbinds from the previous server. 1`] = `"Unhandled \\"error\\" event. (Error: Some error)"`; diff --git a/src/core/server/legacy_compat/__tests__/legacy_kbn_server.test.ts b/src/core/server/legacy_compat/__tests__/legacy_kbn_server.test.ts new file mode 100644 index 00000000000000..62a6f83b98481c --- /dev/null +++ b/src/core/server/legacy_compat/__tests__/legacy_kbn_server.test.ts @@ -0,0 +1,45 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LegacyKbnServer } from '..'; + +test('correctly returns `newPlatformProxyListener`.', () => { + const rawKbnServer = { + newPlatform: { + proxyListener: {}, + }, + }; + + const legacyKbnServer = new LegacyKbnServer(rawKbnServer); + expect(legacyKbnServer.newPlatformProxyListener).toBe(rawKbnServer.newPlatform.proxyListener); +}); + +test('correctly forwards log record.', () => { + const rawKbnServer = { + server: { log: jest.fn() }, + }; + + const legacyKbnServer = new LegacyKbnServer(rawKbnServer); + + const timestamp = new Date(Date.UTC(2012, 1, 1, 11, 22, 33, 44)); + legacyKbnServer.log(['one', 'two'], 'message', timestamp); + legacyKbnServer.log('three', new Error('log error'), timestamp); + + expect(rawKbnServer.server.log.mock.calls).toMatchSnapshot(); +}); diff --git a/src/core/server/legacy_compat/__tests__/legacy_platform_config.test.ts b/src/core/server/legacy_compat/__tests__/legacy_platform_config.test.ts new file mode 100644 index 00000000000000..da6e5f6c9d2550 --- /dev/null +++ b/src/core/server/legacy_compat/__tests__/legacy_platform_config.test.ts @@ -0,0 +1,180 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LegacyConfigToRawConfigAdapter } from '..'; +import { LegacyConfigMock } from '../__mocks__/legacy_config_mock'; + +let legacyConfigMock: LegacyConfigMock; +let configAdapter: LegacyConfigToRawConfigAdapter; +beforeEach(() => { + legacyConfigMock = new LegacyConfigMock(new Map([['__newPlatform', null]])); + configAdapter = new LegacyConfigToRawConfigAdapter(legacyConfigMock); +}); + +describe('#get', () => { + test('correctly handles paths that do not exist in legacy config.', () => { + expect(() => configAdapter.get('one')).toThrowErrorMatchingSnapshot(); + expect(() => configAdapter.get(['one', 'two'])).toThrowErrorMatchingSnapshot(); + expect(() => configAdapter.get(['one.three'])).toThrowErrorMatchingSnapshot(); + }); + + test('returns undefined for new platform config values, even if they do not exist', () => { + expect(configAdapter.get(['__newPlatform', 'plugins'])).toBe(undefined); + }); + + test('returns new platform config values if they exist', () => { + configAdapter = new LegacyConfigToRawConfigAdapter( + new LegacyConfigMock( + new Map([['__newPlatform', { plugins: { scanDirs: ['foo'] } }]]) + ) + ); + expect(configAdapter.get(['__newPlatform', 'plugins'])).toEqual({ + scanDirs: ['foo'], + }); + expect(configAdapter.get('__newPlatform.plugins')).toEqual({ + scanDirs: ['foo'], + }); + }); + + test('correctly handles paths that do not need to be transformed.', () => { + legacyConfigMock.rawData = new Map([ + ['one', 'value-one'], + ['one.sub', 'value-one-sub'], + ['container', { value: 'some' }], + ]); + + expect(configAdapter.get('one')).toEqual('value-one'); + expect(configAdapter.get(['one', 'sub'])).toEqual('value-one-sub'); + expect(configAdapter.get('one.sub')).toEqual('value-one-sub'); + expect(configAdapter.get('container')).toEqual({ value: 'some' }); + }); + + test('correctly handles silent logging config.', () => { + legacyConfigMock.rawData = new Map([['logging', { silent: true }]]); + + expect(configAdapter.get('logging')).toEqual({ + appenders: { + default: { kind: 'legacy-appender' }, + }, + root: { level: 'off' }, + }); + }); + + test('correctly handles verbose file logging config with json format.', () => { + legacyConfigMock.rawData = new Map([ + ['logging', { verbose: true, json: true, dest: '/some/path.log' }], + ]); + + expect(configAdapter.get('logging')).toEqual({ + appenders: { + default: { kind: 'legacy-appender' }, + }, + root: { level: 'all' }, + }); + }); +}); + +describe('#set', () => { + test('tries to set values for paths that do not exist in legacy config.', () => { + expect(() => configAdapter.set('unknown', 'value')).toThrowErrorMatchingSnapshot(); + + expect(() => + configAdapter.set(['unknown', 'sub1'], 'sub-value-1') + ).toThrowErrorMatchingSnapshot(); + + expect(() => configAdapter.set('unknown.sub2', 'sub-value-2')).toThrowErrorMatchingSnapshot(); + }); + + test('correctly sets values for existing paths.', () => { + legacyConfigMock.rawData = new Map([['known', ''], ['known.sub1', ''], ['known.sub2', '']]); + + configAdapter.set('known', 'value'); + configAdapter.set(['known', 'sub1'], 'sub-value-1'); + configAdapter.set('known.sub2', 'sub-value-2'); + + expect(legacyConfigMock.rawData.get('known')).toEqual('value'); + expect(legacyConfigMock.rawData.get('known.sub1')).toEqual('sub-value-1'); + expect(legacyConfigMock.rawData.get('known.sub2')).toEqual('sub-value-2'); + }); + + test('correctly sets values for new platform config.', () => { + legacyConfigMock.rawData = new Map([ + ['__newPlatform', { plugins: { scanDirs: ['foo'] } }], + ]); + + configAdapter = new LegacyConfigToRawConfigAdapter(legacyConfigMock); + + configAdapter.set(['__newPlatform', 'plugins', 'scanDirs'], ['bar']); + expect(legacyConfigMock.rawData.get('__newPlatform')).toMatchSnapshot(); + + configAdapter.set('__newPlatform.plugins.scanDirs', ['baz']); + expect(legacyConfigMock.rawData.get('__newPlatform')).toMatchSnapshot(); + }); +}); + +describe('#has', () => { + test('returns false if config is not set', () => { + expect(configAdapter.has('unknown')).toBe(false); + expect(configAdapter.has(['unknown', 'sub1'])).toBe(false); + expect(configAdapter.has('unknown.sub2')).toBe(false); + }); + + test('returns false if new platform config is not set', () => { + expect(configAdapter.has('__newPlatform.unknown')).toBe(false); + expect(configAdapter.has(['__newPlatform', 'unknown'])).toBe(false); + }); + + test('returns true if config is set.', () => { + legacyConfigMock.rawData = new Map([ + ['known', 'foo'], + ['known.sub1', 'bar'], + ['known.sub2', 'baz'], + ]); + + expect(configAdapter.has('known')).toBe(true); + expect(configAdapter.has(['known', 'sub1'])).toBe(true); + expect(configAdapter.has('known.sub2')).toBe(true); + }); + + test('returns true if new platform config is set.', () => { + legacyConfigMock.rawData = new Map([ + ['__newPlatform', { known: 'foo', known2: { sub: 'bar' } }], + ]); + + configAdapter = new LegacyConfigToRawConfigAdapter(legacyConfigMock); + + expect(configAdapter.has('__newPlatform.known')).toBe(true); + expect(configAdapter.has('__newPlatform.known2')).toBe(true); + expect(configAdapter.has('__newPlatform.known2.sub')).toBe(true); + expect(configAdapter.has(['__newPlatform', 'known'])).toBe(true); + expect(configAdapter.has(['__newPlatform', 'known2'])).toBe(true); + expect(configAdapter.has(['__newPlatform', 'known2', 'sub'])).toBe(true); + }); +}); + +test('`getFlattenedPaths` returns paths from new platform config only.', () => { + legacyConfigMock.rawData = new Map([ + ['__newPlatform', { known: 'foo', known2: { sub: 'bar' } }], + ['legacy', { known: 'baz' }], + ]); + + configAdapter = new LegacyConfigToRawConfigAdapter(legacyConfigMock); + + expect(configAdapter.getFlattenedPaths()).toMatchSnapshot(); +}); diff --git a/src/core/server/legacy_compat/__tests__/legacy_platform_proxifier.test.ts b/src/core/server/legacy_compat/__tests__/legacy_platform_proxifier.test.ts new file mode 100644 index 00000000000000..a441b81bd171e7 --- /dev/null +++ b/src/core/server/legacy_compat/__tests__/legacy_platform_proxifier.test.ts @@ -0,0 +1,200 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EventEmitter } from 'events'; +import { IncomingMessage, ServerResponse } from 'http'; + +class MockNetServer extends EventEmitter { + public address() { + return { port: 1234, family: 'test-family', address: 'test-address' }; + } + + public getConnections(callback: (error: Error | null, count: number) => void) { + callback(null, 100500); + } +} + +function mockNetServer() { + return new MockNetServer(); +} + +jest.mock('net', () => ({ + createServer: jest.fn(() => mockNetServer()), +})); + +import { createServer } from 'net'; +import { LegacyPlatformProxifier } from '..'; + +let root: any; +let proxifier: LegacyPlatformProxifier; +beforeEach(() => { + root = { + logger: { + get: jest.fn(() => ({ + debug: jest.fn(), + info: jest.fn(), + })), + }, + shutdown: jest.fn(), + start: jest.fn(), + } as any; + + proxifier = new LegacyPlatformProxifier(root); +}); + +test('correctly binds to the server.', () => { + const server = createServer(); + jest.spyOn(server, 'addListener'); + proxifier.bind(server); + + expect(server.addListener).toHaveBeenCalledTimes(4); + for (const eventName of ['listening', 'error', 'clientError', 'connection']) { + expect(server.addListener).toHaveBeenCalledWith(eventName, expect.any(Function)); + } +}); + +test('correctly binds to the server and redirects its events.', () => { + const server = createServer(); + proxifier.bind(server); + + const eventsAndListeners = new Map( + ['listening', 'error', 'clientError', 'connection'].map(eventName => { + const listener = jest.fn(); + proxifier.addListener(eventName, listener); + + return [eventName, listener] as [string, () => void]; + }) + ); + + for (const [eventName, listener] of eventsAndListeners) { + expect(listener).not.toHaveBeenCalled(); + + // Emit several events, to make sure that server is not being listened with `once`. + server.emit(eventName, 1, 2, 3, 4); + server.emit(eventName, 5, 6, 7, 8); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledWith(1, 2, 3, 4); + expect(listener).toHaveBeenCalledWith(5, 6, 7, 8); + } +}); + +test('correctly unbinds from the previous server.', () => { + const previousServer = createServer(); + proxifier.bind(previousServer); + + const currentServer = createServer(); + proxifier.bind(currentServer); + + const eventsAndListeners = new Map( + ['listening', 'error', 'clientError', 'connection'].map(eventName => { + const listener = jest.fn(); + proxifier.addListener(eventName, listener); + + return [eventName, listener] as [string, () => void]; + }) + ); + + // Any events from the previous server should not be forwarded. + for (const [eventName, listener] of eventsAndListeners) { + // `error` event is a special case in node, if `error` is emitted, but + // there is no listener for it error will be thrown. + if (eventName === 'error') { + expect(() => + previousServer.emit(eventName, new Error('Some error')) + ).toThrowErrorMatchingSnapshot(); + } else { + previousServer.emit(eventName, 1, 2, 3, 4); + } + + expect(listener).not.toHaveBeenCalled(); + } + + // Only events from the last server should be forwarded. + for (const [eventName, listener] of eventsAndListeners) { + expect(listener).not.toHaveBeenCalled(); + + currentServer.emit(eventName, 1, 2, 3, 4); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(1, 2, 3, 4); + } +}); + +test('returns `address` from the underlying server.', () => { + expect(proxifier.address()).toBeUndefined(); + + proxifier.bind(createServer()); + + expect(proxifier.address()).toEqual({ + address: 'test-address', + family: 'test-family', + port: 1234, + }); +}); + +test('`listen` starts the `root`.', async () => { + const onListenComplete = jest.fn(); + + await proxifier.listen(1234, 'host-1', onListenComplete); + + expect(root.start).toHaveBeenCalledTimes(1); + expect(onListenComplete).toHaveBeenCalledTimes(1); +}); + +test('`close` shuts down the `root`.', async () => { + const onCloseComplete = jest.fn(); + + await proxifier.close(onCloseComplete); + + expect(root.shutdown).toHaveBeenCalledTimes(1); + expect(onCloseComplete).toHaveBeenCalledTimes(1); +}); + +test('returns connection count from the underlying server.', () => { + const onGetConnectionsComplete = jest.fn(); + + proxifier.getConnections(onGetConnectionsComplete); + + expect(onGetConnectionsComplete).toHaveBeenCalledTimes(1); + expect(onGetConnectionsComplete).toHaveBeenCalledWith(null, 0); + onGetConnectionsComplete.mockReset(); + + proxifier.bind(createServer()); + proxifier.getConnections(onGetConnectionsComplete); + + expect(onGetConnectionsComplete).toHaveBeenCalledTimes(1); + expect(onGetConnectionsComplete).toHaveBeenCalledWith(null, 100500); +}); + +test('correctly proxies request and response objects.', () => { + const onRequest = jest.fn(); + proxifier.addListener('request', onRequest); + + const request = {} as IncomingMessage; + const response = {} as ServerResponse; + proxifier.proxy(request, response); + + expect(onRequest).toHaveBeenCalledTimes(1); + expect(onRequest).toHaveBeenCalledWith(request, response); + + // Check that exactly same objects were passed as event arguments. + expect(onRequest.mock.calls[0][0]).toBe(request); + expect(onRequest.mock.calls[0][1]).toBe(response); +}); diff --git a/src/core/server/legacy_compat/index.ts b/src/core/server/legacy_compat/index.ts new file mode 100644 index 00000000000000..aba0f43b812af0 --- /dev/null +++ b/src/core/server/legacy_compat/index.ts @@ -0,0 +1,81 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** @internal */ +export { LegacyPlatformProxifier } from './legacy_platform_proxifier'; +/** @internal */ +export { LegacyConfigToRawConfigAdapter, LegacyConfig } from './legacy_platform_config'; +/** @internal */ +export { LegacyKbnServer } from './legacy_kbn_server'; + +import { + LegacyConfig, + LegacyConfigToRawConfigAdapter, + LegacyKbnServer, + LegacyPlatformProxifier, +} from '.'; +import { BehaviorSubject, k$, map } from '../../lib/kbn_observable'; +import { Env } from '../config'; +import { Root } from '../root'; +import { BasePathProxyRoot } from '../root/base_path_proxy_root'; + +function initEnvironment(rawKbnServer: any) { + const config: LegacyConfig = rawKbnServer.config; + + const legacyConfig$ = new BehaviorSubject(config); + const config$ = k$(legacyConfig$)( + map(legacyConfig => new LegacyConfigToRawConfigAdapter(legacyConfig)) + ); + + const env = Env.createDefault({ + kbnServer: new LegacyKbnServer(rawKbnServer), + // The defaults for the following parameters are retrieved by the legacy + // platform from the command line or from `package.json` and stored in the + // config, so we can borrow these parameters and avoid double parsing. + mode: config.get('env'), + packageInfo: config.get('pkg'), + }); + + return { + config$, + env, + // Propagates legacy config updates to the new platform. + updateConfig(legacyConfig: LegacyConfig) { + legacyConfig$.next(legacyConfig); + }, + }; +} + +/** + * @internal + */ +export const injectIntoKbnServer = (rawKbnServer: any) => { + const { env, config$, updateConfig } = initEnvironment(rawKbnServer); + + rawKbnServer.newPlatform = { + // Custom HTTP Listener that will be used within legacy platform by HapiJS server. + proxyListener: new LegacyPlatformProxifier(new Root(config$, env)), + updateConfig, + }; +}; + +export const createBasePathProxy = (rawKbnServer: any) => { + const { env, config$ } = initEnvironment(rawKbnServer); + return new BasePathProxyRoot(config$, env); +}; diff --git a/src/core/server/legacy_compat/legacy_kbn_server.ts b/src/core/server/legacy_compat/legacy_kbn_server.ts new file mode 100644 index 00000000000000..4ac6a5cede7b50 --- /dev/null +++ b/src/core/server/legacy_compat/legacy_kbn_server.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Represents a wrapper around legacy `kbnServer` instance that exposes only + * a subset of `kbnServer` APIs used by the new platform. + * @internal + */ +export class LegacyKbnServer { + constructor(private readonly rawKbnServer: any) {} + + /** + * Custom HTTP Listener used by HapiJS server in the legacy platform. + */ + get newPlatformProxyListener() { + return this.rawKbnServer.newPlatform.proxyListener; + } + + /** + * Forwards log request to the legacy platform. + * @param tags A string or array of strings used to briefly identify the event. + * @param [data] Optional string or object to log with the event. + * @param [timestamp] Timestamp value associated with the log record. + */ + public log(tags: string | string[], data?: string | Error, timestamp?: Date) { + this.rawKbnServer.server.log(tags, data, timestamp); + } +} diff --git a/src/core/server/legacy_compat/legacy_platform_config.ts b/src/core/server/legacy_compat/legacy_platform_config.ts new file mode 100644 index 00000000000000..523b9285e81026 --- /dev/null +++ b/src/core/server/legacy_compat/legacy_platform_config.ts @@ -0,0 +1,145 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { NEW_PLATFORM_CONFIG_ROOT, ObjectToRawConfigAdapter, RawConfig } from '../config'; +import { ConfigPath } from '../config/config_service'; + +/** + * Represents legacy Kibana config class. + * @internal + */ +export interface LegacyConfig { + get: (configPath: string) => any; + set: (configPath: string, configValue: any) => void; + has: (configPath: string) => boolean; +} + +/** + * Represents logging config supported by the legacy platform. + */ +interface LegacyLoggingConfig { + silent: boolean; + verbose: boolean; + quiet: boolean; + dest: string; + json: boolean; +} + +/** + * Represents adapter between config provided by legacy platform and `RawConfig` + * supported by the current platform. + */ +export class LegacyConfigToRawConfigAdapter implements RawConfig { + private static flattenConfigPath(configPath: ConfigPath) { + if (!Array.isArray(configPath)) { + return configPath; + } + + return configPath.join('.'); + } + + private static transformLogging(configValue: LegacyLoggingConfig) { + const loggingConfig = { + appenders: { default: { kind: 'legacy-appender' } }, + root: { level: 'info' }, + }; + + if (configValue.silent) { + loggingConfig.root.level = 'off'; + } else if (configValue.quiet) { + loggingConfig.root.level = 'error'; + } else if (configValue.verbose) { + loggingConfig.root.level = 'all'; + } + + return loggingConfig; + } + + private static transformServer(configValue: any) { + // TODO: New platform uses just a subset of `server` config from the legacy platform, + // new values will be exposed once we need them (eg. customResponseHeaders, cors or xsrf). + return { + basePath: configValue.basePath, + cors: configValue.cors, + host: configValue.host, + maxPayload: configValue.maxPayloadBytes, + port: configValue.port, + rewriteBasePath: configValue.rewriteBasePath, + ssl: configValue.ssl, + }; + } + + private static isNewPlatformConfig(configPath: ConfigPath) { + if (Array.isArray(configPath)) { + return configPath[0] === NEW_PLATFORM_CONFIG_ROOT; + } + + return configPath.startsWith(NEW_PLATFORM_CONFIG_ROOT); + } + + private newPlatformConfig: ObjectToRawConfigAdapter; + + constructor(private readonly legacyConfig: LegacyConfig) { + this.newPlatformConfig = new ObjectToRawConfigAdapter({ + [NEW_PLATFORM_CONFIG_ROOT]: legacyConfig.get(NEW_PLATFORM_CONFIG_ROOT) || {}, + }); + } + + public has(configPath: ConfigPath) { + if (LegacyConfigToRawConfigAdapter.isNewPlatformConfig(configPath)) { + return this.newPlatformConfig.has(configPath); + } + + return this.legacyConfig.has(LegacyConfigToRawConfigAdapter.flattenConfigPath(configPath)); + } + + public get(configPath: ConfigPath) { + if (LegacyConfigToRawConfigAdapter.isNewPlatformConfig(configPath)) { + return this.newPlatformConfig.get(configPath); + } + + configPath = LegacyConfigToRawConfigAdapter.flattenConfigPath(configPath); + + const configValue = this.legacyConfig.get(configPath); + + switch (configPath) { + case 'logging': + return LegacyConfigToRawConfigAdapter.transformLogging(configValue); + case 'server': + return LegacyConfigToRawConfigAdapter.transformServer(configValue); + default: + return configValue; + } + } + + public set(configPath: ConfigPath, value: any) { + if (LegacyConfigToRawConfigAdapter.isNewPlatformConfig(configPath)) { + return this.newPlatformConfig.set(configPath, value); + } + + this.legacyConfig.set(LegacyConfigToRawConfigAdapter.flattenConfigPath(configPath), value); + } + + public getFlattenedPaths() { + // This method is only used to detect unused config paths, but when we run + // new platform within the legacy one then the new platform is in charge of + // only `__newPlatform` config node and the legacy platform will check the rest. + return this.newPlatformConfig.getFlattenedPaths(); + } +} diff --git a/src/core/server/legacy_compat/legacy_platform_proxifier.ts b/src/core/server/legacy_compat/legacy_platform_proxifier.ts new file mode 100644 index 00000000000000..8e9198799988a9 --- /dev/null +++ b/src/core/server/legacy_compat/legacy_platform_proxifier.ts @@ -0,0 +1,146 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EventEmitter } from 'events'; +import { IncomingMessage, ServerResponse } from 'http'; +import { Server } from 'net'; + +import { Logger } from '../logging'; +import { Root } from '../root'; + +/** + * List of the server events to be forwarded to the legacy platform. + */ +const ServerEventsToForward = ['listening', 'error', 'clientError', 'connection']; + +/** + * Represents "proxy" between legacy and current platform. + * @internal + */ +export class LegacyPlatformProxifier extends EventEmitter { + private readonly eventHandlers: Map void>; + private readonly log: Logger; + private server?: Server; + + constructor(private readonly root: Root) { + super(); + + this.log = root.logger.get('legacy-platform-proxifier'); + + // HapiJS expects that the following events will be generated by `listener`, see: + // https://github.com/hapijs/hapi/blob/v14.2.0/lib/connection.js. + this.eventHandlers = new Map( + ServerEventsToForward.map(eventName => { + return [ + eventName, + (...args: any[]) => { + this.log.debug(`Event is being forwarded: ${eventName}`); + this.emit(eventName, ...args); + }, + ] as [string, (...args: any[]) => void]; + }) + ); + } + + /** + * Neither new nor legacy platform should use this method directly. + */ + public address() { + return this.server && this.server.address(); + } + + /** + * Neither new nor legacy platform should use this method directly. + */ + public async listen(port: number, host: string, callback?: (error?: Error) => void) { + this.log.debug(`"listen" has been called (${host}:${port}).`); + + let error: Error | undefined; + try { + await this.root.start(); + } catch (err) { + error = err; + this.emit('error', err); + } + + if (callback !== undefined) { + callback(error); + } + } + + /** + * Neither new nor legacy platform should use this method directly. + */ + public async close(callback?: (error?: Error) => void) { + this.log.debug('"close" has been called.'); + + let error: Error | undefined; + try { + await this.root.shutdown(); + } catch (err) { + error = err; + this.emit('error', err); + } + + if (callback !== undefined) { + callback(error); + } + } + + /** + * Neither new nor legacy platform should use this method directly. + */ + public getConnections(callback: (error: Error | null, count?: number) => void) { + // This method is used by `even-better` (before we start platform). + // It seems that the latest version of parent `good` doesn't use this anymore. + if (this.server) { + this.server.getConnections(callback); + } else { + callback(null, 0); + } + } + + /** + * Binds Http/Https server to the LegacyPlatformProxifier. + * @param server Server to bind to. + */ + public bind(server: Server) { + const oldServer = this.server; + this.server = server; + + for (const [eventName, eventHandler] of this.eventHandlers) { + if (oldServer !== undefined) { + oldServer.removeListener(eventName, eventHandler); + } + + this.server.addListener(eventName, eventHandler); + } + } + + /** + * Forwards request and response objects to the legacy platform. + * This method is used whenever new platform doesn't know how to handle the request. + * @param request Native Node request object instance. + * @param response Native Node response object instance. + */ + public proxy(request: IncomingMessage, response: ServerResponse) { + this.log.debug(`Request will be handled by proxy ${request.method}:${request.url}.`); + this.emit('request', request, response); + } +} diff --git a/src/core/server/legacy_compat/logging/appenders/__tests__/__snapshots__/legacy_appender.test.ts.snap b/src/core/server/legacy_compat/logging/appenders/__tests__/__snapshots__/legacy_appender.test.ts.snap new file mode 100644 index 00000000000000..4a024fc806a135 --- /dev/null +++ b/src/core/server/legacy_compat/logging/appenders/__tests__/__snapshots__/legacy_appender.test.ts.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`\`append()\` correctly pushes records to legacy platform. 1`] = ` +Array [ + Array [ + Array [ + "trace", + "context-1", + ], + "message-1", + 2012-02-01T11:22:33.044Z, + ], + Array [ + Array [ + "debug", + "context-2", + ], + "message-2", + 2012-02-01T11:22:33.044Z, + ], + Array [ + Array [ + "info", + "context-3", + "sub-context-3", + ], + "message-3", + 2012-02-01T11:22:33.044Z, + ], + Array [ + Array [ + "warn", + "context-4", + "sub-context-4", + ], + "message-4", + 2012-02-01T11:22:33.044Z, + ], + Array [ + Array [ + "error", + "context-5", + ], + [Error: Some Error], + 2012-02-01T11:22:33.044Z, + ], + Array [ + Array [ + "error", + "context-6", + ], + "message-6-with-message", + 2012-02-01T11:22:33.044Z, + ], + Array [ + Array [ + "fatal", + "context-7", + "sub-context-7", + "sub-sub-context-7", + ], + [Error: Some Fatal Error], + 2012-02-01T11:22:33.044Z, + ], + Array [ + Array [ + "fatal", + "context-8", + "sub-context-8", + "sub-sub-context-8", + ], + "message-8-with-message", + 2012-02-01T11:22:33.044Z, + ], +] +`; diff --git a/src/core/server/legacy_compat/logging/appenders/__tests__/legacy_appender.test.ts b/src/core/server/legacy_compat/logging/appenders/__tests__/legacy_appender.test.ts new file mode 100644 index 00000000000000..25fe60b55c6d60 --- /dev/null +++ b/src/core/server/legacy_compat/logging/appenders/__tests__/legacy_appender.test.ts @@ -0,0 +1,101 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LogLevel } from '../../../../logging/log_level'; +import { LogRecord } from '../../../../logging/log_record'; +import { LegacyKbnServer } from '../../../legacy_kbn_server'; +import { LegacyAppender } from '../legacy_appender'; + +test('`configSchema` creates correct schema.', () => { + const appenderSchema = LegacyAppender.configSchema; + const validConfig = { kind: 'legacy-appender' }; + expect(appenderSchema.validate(validConfig)).toEqual({ + kind: 'legacy-appender', + }); + + const wrongConfig = { kind: 'not-legacy-appender' }; + expect(() => appenderSchema.validate(wrongConfig)).toThrow(); +}); + +test('`append()` correctly pushes records to legacy platform.', () => { + const timestamp = new Date(Date.UTC(2012, 1, 1, 11, 22, 33, 44)); + const records: LogRecord[] = [ + { + context: 'context-1', + level: LogLevel.Trace, + message: 'message-1', + timestamp, + }, + { + context: 'context-2', + level: LogLevel.Debug, + message: 'message-2', + timestamp, + }, + { + context: 'context-3.sub-context-3', + level: LogLevel.Info, + message: 'message-3', + timestamp, + }, + { + context: 'context-4.sub-context-4', + level: LogLevel.Warn, + message: 'message-4', + timestamp, + }, + { + context: 'context-5', + error: new Error('Some Error'), + level: LogLevel.Error, + message: 'message-5-with-error', + timestamp, + }, + { + context: 'context-6', + level: LogLevel.Error, + message: 'message-6-with-message', + timestamp, + }, + { + context: 'context-7.sub-context-7.sub-sub-context-7', + error: new Error('Some Fatal Error'), + level: LogLevel.Fatal, + message: 'message-7-with-error', + timestamp, + }, + { + context: 'context-8.sub-context-8.sub-sub-context-8', + level: LogLevel.Fatal, + message: 'message-8-with-message', + timestamp, + }, + ]; + + const rawKbnServerMock = { + server: { log: jest.fn() }, + }; + const appender = new LegacyAppender(new LegacyKbnServer(rawKbnServerMock)); + + for (const record of records) { + appender.append(record); + } + + expect(rawKbnServerMock.server.log.mock.calls).toMatchSnapshot(); +}); diff --git a/src/core/server/legacy_compat/logging/appenders/legacy_appender.ts b/src/core/server/legacy_compat/logging/appenders/legacy_appender.ts new file mode 100644 index 00000000000000..e2c7f996b80133 --- /dev/null +++ b/src/core/server/legacy_compat/logging/appenders/legacy_appender.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../../../config/schema'; + +import { DisposableAppender } from '../../../logging/appenders/appenders'; +import { LogRecord } from '../../../logging/log_record'; +import { LegacyKbnServer } from '../../legacy_kbn_server'; + +const { literal, object } = schema; + +/** + * Simple appender that just forwards `LogRecord` to the legacy KbnServer log. + * @internal + */ +export class LegacyAppender implements DisposableAppender { + public static configSchema = object({ + kind: literal('legacy-appender'), + }); + + constructor(private readonly kbnServer: LegacyKbnServer) {} + + /** + * Forwards `LogRecord` to the legacy platform that will layout and + * write record to the configured destination. + * @param record `LogRecord` instance to forward to. + */ + public append(record: LogRecord) { + this.kbnServer.log( + [record.level.id.toLowerCase(), ...record.context.split('.')], + record.error || record.message, + record.timestamp + ); + } + + public async dispose() { + // noop + } +} diff --git a/src/core/server/logging/README.md b/src/core/server/logging/README.md new file mode 100644 index 00000000000000..65fe64b0458018 --- /dev/null +++ b/src/core/server/logging/README.md @@ -0,0 +1,181 @@ +# Logging + +The way logging works in Kibana is inspired by `log4j 2` logging framework used by [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/settings.html#logging). +The main idea is to have consistent logging behaviour (configuration, log format etc.) across the entire Elastic Stack +where possible. + +## Loggers, Appenders and Layouts + +Kibana logging system has three main components: _loggers_, _appenders_ and _layouts_. These components allow us to log +messages according to message type and level, and to control how these messages are formatted and where the final logs +will be displayed or stored. + +__Loggers__ define what logging settings should be applied at the particular context. + +__Appenders__ define where log messages are displayed (eg. stdout or console) and stored (eg. file on the disk). + +__Layouts__ define how log messages are formatted and what type of information they include. + + +## Logger hierarchy + +Every logger has its unique name or context that follows hierarchical naming rule. The logger is considered to be an +ancestor of another logger if its name followed by a `.` is a prefix of the descendant logger name. For example logger +with `a.b` context is an ancestor of logger with `a.b.c` context. All top-level loggers are descendants of special +logger with `root` context that resides at the top of the logger hierarchy. This logger always exists and +fully configured. + +Developer can configure _log level_ and _appenders_ that should be used within particular context. If logger configuration +specifies only _log level_ then _appenders_ configuration will be inherited from the ancestor logger. + +__Note:__ in the current implementation log messages are only forwarded to appenders configured for a particular logger +context or to appenders of the closest ancestor if current logger doesn't have any appenders configured. That means that +we __don't support__ so called _appender additivity_ when log messages are forwarded to _every_ distinct appender within +ancestor chain including `root`. + +## Log level + +Currently we support the following log levels: _all_, _fatal_, _error_, _warn_, _info_, _debug_, _trace_, _off_. +Levels are ordered, so _all_ > _fatal_ > _error_ > _warn_ > _info_ > _debug_ > _trace_ > _off_. +A log record is being logged by the logger if its level is higher than or equal to the level of its logger. Otherwise, +the log record is ignored. + +The _all_ and _off_ levels can be used only in configuration and are just handy shortcuts that allow developer to log every +log record or disable logging entirely for the specific context. + +## Layouts + +Every appender should know exactly how to format log messages before they are written to the console or file on the disk. +This behaviour is controlled by the layouts and configured through `appender.layout` configuration property for every +custom appender (see examples in [Configuration](#configuration)). Currently we don't define any default layout for the +custom appenders, so one should always make the choice explicitly. + +There are two types of layout supported at the moment: `pattern` and `json`. + +With `pattern` layout it's possible to define a string pattern with special placeholders wrapped into curly braces that +will be replaced with data from the actual log message. By default the following pattern is used: +`[{timestamp}][{level}][{context}] {message}`. Also `highlight` option can be enabled for `pattern` layout so that +some parts of the log message are highlighted with different colors that may be quite handy if log messages are forwarded +to the terminal with color support. + +With `json` layout log messages will be formatted as JSON strings that include timestamp, log level, context, message +text and any other metadata that may be associated with the log message itself. + +## Configuration + +As any configuration in the platform, logging configuration is validated against the predefined schema and if there are +any issues with it, Kibana will fail to start with the detailed error message. + +Once the code acquired a logger instance it should not care about any runtime changes in the configuration that may +happen: all changes will be applied to existing logger instances under the hood. + +Here is the configuration example that can be used to configure _loggers_, _appenders_ and _layouts_: + +```yaml +logging: + appenders: + console: + kind: console + layout: + kind: pattern + highlight: true + file: + kind: file + path: /var/log/kibana.log + layout: + kind: pattern + custom: + kind: console + layout: + kind: pattern + pattern: [{timestamp}][{level}] {message} + json-file-appender: + kind: file + path: /var/log/kibana-json.log + + root: + appenders: [console, file] + level: error + + loggers: + - context: plugins + appenders: [custom] + level: warn + - context: plugins.pid + level: info + - context: server + level: fatal + - context: optimize + appenders: [console] + - context: telemetry + level: all + appenders: [json-file-appender] +``` + +Here is what we get with the config above: + +| Context | Appenders | Level | +| ------------- |:------------------------:| -----:| +| root | console, file | error | +| plugins | custom | warn | +| plugins.pid | custom | info | +| server | console, file | fatal | +| optimize | console | error | +| telemetry | json-file-appender | all | + + +The `root` logger has a dedicated configuration node since this context is special and should always exist. By +default `root` is configured with `info` level and `default` appender that is also always available. This is the +configuration that all custom loggers will use unless they're re-configured explicitly. + +For example to see _all_ log messages that fall back on the `root` logger configuration, just add one line to the configuration: + +```yaml +logging.root.level: all +``` + +Or disable logging entirely with `off`: + +```yaml +logging.root.level: off +``` + +## Usage + +Usage is very straightforward, one should just get a logger for a specific context and use it to log messages with +different log level. + +```typescript +const logger = kibana.logger.get('server'); + +logger.trace('Message with `trace` log level.'); +logger.debug('Message with `debug` log level.'); +logger.info('Message with `info` log level.'); +logger.warn('Message with `warn` log level.'); +logger.error('Message with `error` log level.'); +logger.fatal('Message with `fatal` log level.'); + +const loggerWithNestedContext = kibana.logger.get('server', 'http'); +loggerWithNestedContext.trace('Message with `trace` log level.'); +loggerWithNestedContext.debug('Message with `debug` log level.'); +``` + +And assuming logger for `server` context with `console` appender and `trace` level was used, console output will look like this: +```bash +[2017-07-25T18:54:41.639Z][TRACE][server] Message with `trace` log level. +[2017-07-25T18:54:41.639Z][DEBUG][server] Message with `debug` log level. +[2017-07-25T18:54:41.639Z][INFO ][server] Message with `info` log level. +[2017-07-25T18:54:41.639Z][WARN ][server] Message with `warn` log level. +[2017-07-25T18:54:41.639Z][ERROR][server] Message with `error` log level. +[2017-07-25T18:54:41.639Z][FATAL][server] Message with `fatal` log level. + +[2017-07-25T18:54:41.639Z][TRACE][server.http] Message with `trace` log level. +[2017-07-25T18:54:41.639Z][DEBUG][server.http] Message with `debug` log level. +``` + +The log will be less verbose with `warn` level for the `server` context: +```bash +[2017-07-25T18:54:41.639Z][WARN ][server] Message with `warn` log level. +[2017-07-25T18:54:41.639Z][ERROR][server] Message with `error` log level. +[2017-07-25T18:54:41.639Z][FATAL][server] Message with `fatal` log level. +``` diff --git a/src/core/server/logging/__mocks__/index.ts b/src/core/server/logging/__mocks__/index.ts new file mode 100644 index 00000000000000..400521f6077fb0 --- /dev/null +++ b/src/core/server/logging/__mocks__/index.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Test helpers to simplify mocking logs and collecting all their outputs + +const mockLog = { + debug: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + info: jest.fn(), + log: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), +}; + +const mockClear = () => { + logger.get.mockClear(); + mockLog.debug.mockClear(); + mockLog.info.mockClear(); + mockLog.warn.mockClear(); + mockLog.error.mockClear(); + mockLog.trace.mockClear(); + mockLog.fatal.mockClear(); + mockLog.log.mockClear(); +}; + +const mockCollect = () => ({ + debug: mockLog.debug.mock.calls, + error: mockLog.error.mock.calls, + fatal: mockLog.fatal.mock.calls, + info: mockLog.info.mock.calls, + log: mockLog.log.mock.calls, + trace: mockLog.trace.mock.calls, + warn: mockLog.warn.mock.calls, +}); + +export const logger = { + get: jest.fn(() => mockLog), + mockClear, + mockCollect, + mockLog, +}; diff --git a/src/core/server/logging/__tests__/__snapshots__/logging_config.test.ts.snap b/src/core/server/logging/__tests__/__snapshots__/logging_config.test.ts.snap new file mode 100644 index 00000000000000..10509b20e89423 --- /dev/null +++ b/src/core/server/logging/__tests__/__snapshots__/logging_config.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`\`schema\` creates correct schema with defaults. 1`] = ` +Object { + "appenders": Map {}, + "loggers": Array [], + "root": Object { + "appenders": Array [ + "default", + ], + "level": "info", + }, +} +`; + +exports[`\`schema\` throws if \`root\` logger does not have appenders configured. 1`] = `"[root.appenders]: array size is [0], but cannot be smaller than [1]"`; + +exports[`fails if loggers use unknown appenders. 1`] = `"Logger \\"some.nested.context\\" contains unsupported appender key \\"unknown\\"."`; diff --git a/src/core/server/logging/__tests__/log_level.test.ts b/src/core/server/logging/__tests__/log_level.test.ts new file mode 100644 index 00000000000000..43de344b34cffb --- /dev/null +++ b/src/core/server/logging/__tests__/log_level.test.ts @@ -0,0 +1,123 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LogLevel } from '../log_level'; + +const allLogLevels = [ + LogLevel.Off, + LogLevel.Fatal, + LogLevel.Error, + LogLevel.Warn, + LogLevel.Info, + LogLevel.Debug, + LogLevel.Trace, + LogLevel.All, +]; + +test('`LogLevel.All` supports all log levels.', () => { + for (const level of allLogLevels) { + expect(LogLevel.All.supports(level)).toBe(true); + } +}); + +test('`LogLevel.Trace` supports `Trace, Debug, Info, Warn, Error, Fatal, Off`.', () => { + const supportedLevels = [ + LogLevel.Off, + LogLevel.Fatal, + LogLevel.Error, + LogLevel.Warn, + LogLevel.Info, + LogLevel.Debug, + LogLevel.Trace, + ]; + + for (const level of allLogLevels) { + expect(LogLevel.Trace.supports(level)).toBe(supportedLevels.includes(level)); + } +}); + +test('`LogLevel.Debug` supports `Debug, Info, Warn, Error, Fatal, Off`.', () => { + const supportedLevels = [ + LogLevel.Off, + LogLevel.Fatal, + LogLevel.Error, + LogLevel.Warn, + LogLevel.Info, + LogLevel.Debug, + ]; + + for (const level of allLogLevels) { + expect(LogLevel.Debug.supports(level)).toBe(supportedLevels.includes(level)); + } +}); + +test('`LogLevel.Info` supports `Info, Warn, Error, Fatal, Off`.', () => { + const supportedLevels = [ + LogLevel.Off, + LogLevel.Fatal, + LogLevel.Error, + LogLevel.Warn, + LogLevel.Info, + ]; + + for (const level of allLogLevels) { + expect(LogLevel.Info.supports(level)).toBe(supportedLevels.includes(level)); + } +}); + +test('`LogLevel.Warn` supports `Warn, Error, Fatal, Off`.', () => { + const supportedLevels = [LogLevel.Off, LogLevel.Fatal, LogLevel.Error, LogLevel.Warn]; + + for (const level of allLogLevels) { + expect(LogLevel.Warn.supports(level)).toBe(supportedLevels.includes(level)); + } +}); + +test('`LogLevel.Error` supports `Error, Fatal, Off`.', () => { + const supportedLevels = [LogLevel.Off, LogLevel.Fatal, LogLevel.Error]; + + for (const level of allLogLevels) { + expect(LogLevel.Error.supports(level)).toBe(supportedLevels.includes(level)); + } +}); + +test('`LogLevel.Fatal` supports `Fatal, Off`.', () => { + const supportedLevels = [LogLevel.Off, LogLevel.Fatal]; + + for (const level of allLogLevels) { + expect(LogLevel.Fatal.supports(level)).toBe(supportedLevels.includes(level)); + } +}); + +test('`LogLevel.Off` supports only itself.', () => { + for (const level of allLogLevels) { + expect(LogLevel.Off.supports(level)).toBe(level === LogLevel.Off); + } +}); + +test('`fromId()` correctly converts string log level value to `LogLevel` instance.', () => { + expect(LogLevel.fromId('all')).toBe(LogLevel.All); + expect(LogLevel.fromId('trace')).toBe(LogLevel.Trace); + expect(LogLevel.fromId('debug')).toBe(LogLevel.Debug); + expect(LogLevel.fromId('info')).toBe(LogLevel.Info); + expect(LogLevel.fromId('warn')).toBe(LogLevel.Warn); + expect(LogLevel.fromId('error')).toBe(LogLevel.Error); + expect(LogLevel.fromId('fatal')).toBe(LogLevel.Fatal); + expect(LogLevel.fromId('off')).toBe(LogLevel.Off); +}); diff --git a/src/core/server/logging/__tests__/logger.test.ts b/src/core/server/logging/__tests__/logger.test.ts new file mode 100644 index 00000000000000..2dc16178fb47b9 --- /dev/null +++ b/src/core/server/logging/__tests__/logger.test.ts @@ -0,0 +1,390 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Appender } from '../appenders/appenders'; +import { LogLevel } from '../log_level'; +import { BaseLogger } from '../logger'; +import { LoggingConfig } from '../logging_config'; + +const context = LoggingConfig.getLoggerContext(['context', 'parent', 'child']); +let appenderMocks: Appender[]; +let logger: BaseLogger; + +const timestamp = new Date(2012, 1, 1); +jest.spyOn(global, 'Date').mockImplementation(() => timestamp); + +beforeEach(() => { + appenderMocks = [{ append: jest.fn() }, { append: jest.fn() }]; + logger = new BaseLogger(context, LogLevel.All, appenderMocks); +}); + +test('`trace()` correctly forms `LogRecord` and passes it to all appenders.', () => { + logger.trace('message-1'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(1); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Trace, + message: 'message-1', + meta: undefined, + timestamp, + }); + } + + logger.trace('message-2', { trace: true }); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(2); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Trace, + message: 'message-2', + meta: { trace: true }, + timestamp, + }); + } +}); + +test('`debug()` correctly forms `LogRecord` and passes it to all appenders.', () => { + logger.debug('message-1'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(1); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Debug, + message: 'message-1', + meta: undefined, + timestamp, + }); + } + + logger.debug('message-2', { debug: true }); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(2); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Debug, + message: 'message-2', + meta: { debug: true }, + timestamp, + }); + } +}); + +test('`info()` correctly forms `LogRecord` and passes it to all appenders.', () => { + logger.info('message-1'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(1); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Info, + message: 'message-1', + meta: undefined, + timestamp, + }); + } + + logger.info('message-2', { info: true }); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(2); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Info, + message: 'message-2', + meta: { info: true }, + timestamp, + }); + } +}); + +test('`warn()` correctly forms `LogRecord` and passes it to all appenders.', () => { + logger.warn('message-1'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(1); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Warn, + message: 'message-1', + meta: undefined, + timestamp, + }); + } + + const error = new Error('message-2'); + logger.warn(error); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(2); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error, + level: LogLevel.Warn, + message: 'message-2', + meta: undefined, + timestamp, + }); + } + + logger.warn('message-3', { warn: true }); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(3); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Warn, + message: 'message-3', + meta: { warn: true }, + timestamp, + }); + } +}); + +test('`error()` correctly forms `LogRecord` and passes it to all appenders.', () => { + logger.error('message-1'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(1); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Error, + message: 'message-1', + meta: undefined, + timestamp, + }); + } + + const error = new Error('message-2'); + logger.error(error); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(2); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error, + level: LogLevel.Error, + message: 'message-2', + meta: undefined, + timestamp, + }); + } + + logger.error('message-3', { error: true }); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(3); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Error, + message: 'message-3', + meta: { error: true }, + timestamp, + }); + } +}); + +test('`fatal()` correctly forms `LogRecord` and passes it to all appenders.', () => { + logger.fatal('message-1'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(1); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Fatal, + message: 'message-1', + meta: undefined, + timestamp, + }); + } + + const error = new Error('message-2'); + logger.fatal(error); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(2); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error, + level: LogLevel.Fatal, + message: 'message-2', + meta: undefined, + timestamp, + }); + } + + logger.fatal('message-3', { fatal: true }); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(3); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + error: undefined, + level: LogLevel.Fatal, + message: 'message-3', + meta: { fatal: true }, + timestamp, + }); + } +}); + +test('`log()` just passes the record to all appenders.', () => { + const record = { + context, + level: LogLevel.Info, + message: 'message-1', + timestamp, + }; + + logger.log(record); + + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(1); + expect(appenderMock.append).toHaveBeenCalledWith(record); + } +}); + +test('logger with `Off` level does not pass any records to appenders.', () => { + const turnedOffLogger = new BaseLogger(context, LogLevel.Off, appenderMocks); + turnedOffLogger.trace('trace-message'); + turnedOffLogger.debug('debug-message'); + turnedOffLogger.info('info-message'); + turnedOffLogger.warn('warn-message'); + turnedOffLogger.error('error-message'); + turnedOffLogger.fatal('fatal-message'); + + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).not.toHaveBeenCalled(); + } +}); + +test('logger with `All` level passes all records to appenders.', () => { + const catchAllLogger = new BaseLogger(context, LogLevel.All, appenderMocks); + + catchAllLogger.trace('trace-message'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(1); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + level: LogLevel.Trace, + message: 'trace-message', + timestamp, + }); + } + + catchAllLogger.debug('debug-message'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(2); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + level: LogLevel.Debug, + message: 'debug-message', + timestamp, + }); + } + + catchAllLogger.info('info-message'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(3); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + level: LogLevel.Info, + message: 'info-message', + timestamp, + }); + } + + catchAllLogger.warn('warn-message'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(4); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + level: LogLevel.Warn, + message: 'warn-message', + timestamp, + }); + } + + catchAllLogger.error('error-message'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(5); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + level: LogLevel.Error, + message: 'error-message', + timestamp, + }); + } + + catchAllLogger.fatal('fatal-message'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(6); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + level: LogLevel.Fatal, + message: 'fatal-message', + timestamp, + }); + } +}); + +test('passes log record to appenders only if log level is supported.', () => { + const warnLogger = new BaseLogger(context, LogLevel.Warn, appenderMocks); + + warnLogger.trace('trace-message'); + warnLogger.debug('debug-message'); + warnLogger.info('info-message'); + + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).not.toHaveBeenCalled(); + } + + warnLogger.warn('warn-message'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(1); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + level: LogLevel.Warn, + message: 'warn-message', + timestamp, + }); + } + + warnLogger.error('error-message'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(2); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + level: LogLevel.Error, + message: 'error-message', + timestamp, + }); + } + + warnLogger.fatal('fatal-message'); + for (const appenderMock of appenderMocks) { + expect(appenderMock.append).toHaveBeenCalledTimes(3); + expect(appenderMock.append).toHaveBeenCalledWith({ + context, + level: LogLevel.Fatal, + message: 'fatal-message', + timestamp, + }); + } +}); diff --git a/src/core/server/logging/__tests__/logger_adapter.test.ts b/src/core/server/logging/__tests__/logger_adapter.test.ts new file mode 100644 index 00000000000000..25a9c01b108d69 --- /dev/null +++ b/src/core/server/logging/__tests__/logger_adapter.test.ts @@ -0,0 +1,94 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Logger } from '../logger'; +import { LoggerAdapter } from '../logger_adapter'; + +test('proxies all method calls to the internal logger.', () => { + const internalLogger: Logger = { + debug: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + info: jest.fn(), + log: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }; + + const adapter = new LoggerAdapter(internalLogger); + + adapter.trace('trace-message'); + expect(internalLogger.trace).toHaveBeenCalledTimes(1); + expect(internalLogger.trace).toHaveBeenCalledWith('trace-message', undefined); + + adapter.debug('debug-message'); + expect(internalLogger.debug).toHaveBeenCalledTimes(1); + expect(internalLogger.debug).toHaveBeenCalledWith('debug-message', undefined); + + adapter.info('info-message'); + expect(internalLogger.info).toHaveBeenCalledTimes(1); + expect(internalLogger.info).toHaveBeenCalledWith('info-message', undefined); + + adapter.warn('warn-message'); + expect(internalLogger.warn).toHaveBeenCalledTimes(1); + expect(internalLogger.warn).toHaveBeenCalledWith('warn-message', undefined); + + adapter.error('error-message'); + expect(internalLogger.error).toHaveBeenCalledTimes(1); + expect(internalLogger.error).toHaveBeenCalledWith('error-message', undefined); + + adapter.fatal('fatal-message'); + expect(internalLogger.fatal).toHaveBeenCalledTimes(1); + expect(internalLogger.fatal).toHaveBeenCalledWith('fatal-message', undefined); +}); + +test('forwards all method calls to new internal logger if it is updated.', () => { + const oldInternalLogger: Logger = { + debug: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + info: jest.fn(), + log: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }; + + const newInternalLogger: Logger = { + debug: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + info: jest.fn(), + log: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }; + + const adapter = new LoggerAdapter(oldInternalLogger); + + adapter.trace('trace-message'); + expect(oldInternalLogger.trace).toHaveBeenCalledTimes(1); + expect(oldInternalLogger.trace).toHaveBeenCalledWith('trace-message', undefined); + (oldInternalLogger.trace as jest.Mock<() => void>).mockReset(); + + adapter.updateLogger(newInternalLogger); + adapter.trace('trace-message'); + expect(oldInternalLogger.trace).not.toHaveBeenCalled(); + expect(newInternalLogger.trace).toHaveBeenCalledTimes(1); + expect(newInternalLogger.trace).toHaveBeenCalledWith('trace-message', undefined); +}); diff --git a/src/core/server/logging/__tests__/logger_factory.test.ts b/src/core/server/logging/__tests__/logger_factory.test.ts new file mode 100644 index 00000000000000..f1d5fd798157ee --- /dev/null +++ b/src/core/server/logging/__tests__/logger_factory.test.ts @@ -0,0 +1,168 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const mockCreateWriteStream: any = {}; + +jest.mock('fs', () => ({ + createWriteStream: () => mockCreateWriteStream, +})); + +import { MutableLoggerFactory } from '../logger_factory'; +import { LoggingConfig } from '../logging_config'; + +const tickMs = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const mockStreamEndFinished = jest.fn(); +mockCreateWriteStream.write = jest.fn(); +mockCreateWriteStream.end = jest.fn(async (chunk, encoding, callback) => { + // It's required to make sure `dispose` waits for `end` to complete. + await tickMs(100); + mockStreamEndFinished(); + callback(); +}); + +const timestamp = new Date(Date.UTC(2012, 1, 1)); +const mockConsoleLog = jest.spyOn(global.console, 'log').mockImplementation(() => { + // noop +}); +jest.spyOn(global, 'Date').mockImplementation(() => timestamp); + +beforeEach(() => { + mockCreateWriteStream.write.mockClear(); + mockCreateWriteStream.end.mockClear(); + mockStreamEndFinished.mockClear(); + mockConsoleLog.mockClear(); +}); + +test('`get()` returns Logger that appends records to buffer if config is not ready.', () => { + const factory = new MutableLoggerFactory({} as any); + const loggerWithoutConfig = factory.get('some-context'); + const testsLogger = factory.get('tests'); + const testsChildLogger = factory.get('tests', 'child'); + + loggerWithoutConfig.info('You know, just for your info.'); + testsLogger.warn('Config is not ready!'); + testsChildLogger.error('Too bad that config is not ready :/'); + testsChildLogger.info('Just some info that should not be logged.'); + + expect(mockConsoleLog).not.toHaveBeenCalled(); + expect(mockCreateWriteStream.write).not.toHaveBeenCalled(); + + const loggingConfigSchema = LoggingConfig.schema; + const config = new LoggingConfig( + loggingConfigSchema.validate({ + appenders: { + default: { + kind: 'console', + layout: { kind: 'pattern' }, + }, + file: { + kind: 'file', + layout: { kind: 'pattern' }, + path: 'path', + }, + }, + loggers: [ + { + appenders: ['file'], + context: 'tests', + level: 'warn', + }, + { + context: 'tests.child', + level: 'error', + }, + ], + }) + ); + + factory.updateConfig(config); + + // Now all logs should added to configured appenders. + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenCalledWith( + '[2012-02-01T00:00:00.000Z][INFO ][some-context] You know, just for your info.' + ); + + expect(mockCreateWriteStream.write).toHaveBeenCalledTimes(2); + expect(mockCreateWriteStream.write).toHaveBeenCalledWith( + '[2012-02-01T00:00:00.000Z][WARN ][tests] Config is not ready!\n' + ); + expect(mockCreateWriteStream.write).toHaveBeenCalledWith( + '[2012-02-01T00:00:00.000Z][ERROR][tests.child] Too bad that config is not ready :/\n' + ); +}); + +test('`get()` returns `root` logger if context is not specified.', () => { + const loggingConfigSchema = LoggingConfig.schema; + const factory = new MutableLoggerFactory({} as any); + const config = loggingConfigSchema.validate({ + appenders: { + default: { + kind: 'console', + layout: { kind: 'pattern' }, + }, + }, + }); + factory.updateConfig(new LoggingConfig(config)); + + const rootLogger = factory.get(); + + rootLogger.info('This message goes to a root context.'); + + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenCalledWith( + '[2012-02-01T00:00:00.000Z][INFO ][root] This message goes to a root context.' + ); +}); + +test('`close()` disposes all resources used by appenders.', async () => { + const factory = new MutableLoggerFactory({} as any); + + const loggingConfigSchema = LoggingConfig.schema; + const config = new LoggingConfig( + loggingConfigSchema.validate({ + appenders: { + default: { + kind: 'file', + layout: { kind: 'pattern' }, + path: 'path', + }, + }, + }) + ); + + factory.updateConfig(config); + + const logger = factory.get('some-context'); + logger.info('You know, just for your info.'); + + expect(mockCreateWriteStream.write).toHaveBeenCalled(); + expect(mockCreateWriteStream.end).not.toHaveBeenCalled(); + + await factory.close(); + + expect(mockCreateWriteStream.end).toHaveBeenCalledTimes(1); + expect(mockCreateWriteStream.end).toHaveBeenCalledWith( + undefined, + undefined, + expect.any(Function) + ); + expect(mockStreamEndFinished).toHaveBeenCalled(); +}); diff --git a/src/core/server/logging/__tests__/logging_config.test.ts b/src/core/server/logging/__tests__/logging_config.test.ts new file mode 100644 index 00000000000000..2f1f1d9f2f7c02 --- /dev/null +++ b/src/core/server/logging/__tests__/logging_config.test.ts @@ -0,0 +1,178 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LoggingConfig } from '../logging_config'; + +test('`schema` creates correct schema with defaults.', () => { + const loggingConfigSchema = LoggingConfig.schema; + expect(loggingConfigSchema.validate({})).toMatchSnapshot(); +}); + +test('`schema` throws if `root` logger does not have appenders configured.', () => { + const loggingConfigSchema = LoggingConfig.schema; + + expect(() => + loggingConfigSchema.validate({ + root: { + appenders: [], + }, + }) + ).toThrowErrorMatchingSnapshot(); +}); + +test('`getParentLoggerContext()` returns correct parent context name.', () => { + expect(LoggingConfig.getParentLoggerContext('a.b.c')).toEqual('a.b'); + expect(LoggingConfig.getParentLoggerContext('a.b')).toEqual('a'); + expect(LoggingConfig.getParentLoggerContext('a')).toEqual('root'); +}); + +test('`getLoggerContext()` returns correct joined context name.', () => { + expect(LoggingConfig.getLoggerContext(['a', 'b', 'c'])).toEqual('a.b.c'); + expect(LoggingConfig.getLoggerContext(['a', 'b'])).toEqual('a.b'); + expect(LoggingConfig.getLoggerContext(['a'])).toEqual('a'); + expect(LoggingConfig.getLoggerContext([])).toEqual('root'); +}); + +test('correctly fills in default `appenders` config.', () => { + const loggingConfigSchema = LoggingConfig.schema; + const config = new LoggingConfig(loggingConfigSchema.validate({})); + + expect(config.appenders.size).toBe(1); + + expect(config.appenders.get('default')).toEqual({ + kind: 'console', + layout: { kind: 'pattern', highlight: true }, + }); +}); + +test('correctly fills in custom `appenders` config.', () => { + const loggingConfigSchema = LoggingConfig.schema; + const config = new LoggingConfig( + loggingConfigSchema.validate({ + appenders: { + console: { + kind: 'console', + layout: { kind: 'pattern' }, + }, + file: { + kind: 'file', + layout: { kind: 'pattern' }, + path: 'path', + }, + }, + }) + ); + + expect(config.appenders.size).toBe(3); + + expect(config.appenders.get('default')).toEqual({ + kind: 'console', + layout: { kind: 'pattern', highlight: true }, + }); + + expect(config.appenders.get('console')).toEqual({ + kind: 'console', + layout: { kind: 'pattern' }, + }); + + expect(config.appenders.get('file')).toEqual({ + kind: 'file', + layout: { kind: 'pattern' }, + path: 'path', + }); +}); + +test('correctly fills in default `loggers` config.', () => { + const loggingConfigSchema = LoggingConfig.schema; + const config = new LoggingConfig(loggingConfigSchema.validate({})); + + expect(config.loggers.size).toBe(1); + expect(config.loggers.get('root')).toEqual({ + appenders: ['default'], + context: 'root', + level: 'info', + }); +}); + +test('correctly fills in custom `loggers` config.', () => { + const loggingConfigSchema = LoggingConfig.schema; + const config = new LoggingConfig( + loggingConfigSchema.validate({ + appenders: { + file: { + kind: 'file', + layout: { kind: 'pattern' }, + path: 'path', + }, + }, + loggers: [ + { + appenders: ['file'], + context: 'plugins', + level: 'warn', + }, + { + context: 'plugins.pid', + level: 'trace', + }, + { + appenders: ['default'], + context: 'http', + level: 'error', + }, + ], + }) + ); + + expect(config.loggers.size).toBe(4); + expect(config.loggers.get('root')).toEqual({ + appenders: ['default'], + context: 'root', + level: 'info', + }); + expect(config.loggers.get('plugins')).toEqual({ + appenders: ['file'], + context: 'plugins', + level: 'warn', + }); + expect(config.loggers.get('plugins.pid')).toEqual({ + appenders: ['file'], + context: 'plugins.pid', + level: 'trace', + }); + expect(config.loggers.get('http')).toEqual({ + appenders: ['default'], + context: 'http', + level: 'error', + }); +}); + +test('fails if loggers use unknown appenders.', () => { + const loggingConfigSchema = LoggingConfig.schema; + const validateConfig = loggingConfigSchema.validate({ + loggers: [ + { + appenders: ['unknown'], + context: 'some.nested.context', + }, + ], + }); + + expect(() => new LoggingConfig(validateConfig)).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/logging/__tests__/logging_service.test.ts b/src/core/server/logging/__tests__/logging_service.test.ts new file mode 100644 index 00000000000000..b97bd0b00a8bd9 --- /dev/null +++ b/src/core/server/logging/__tests__/logging_service.test.ts @@ -0,0 +1,86 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BehaviorSubject } from '../../../lib/kbn_observable'; +import { MutableLoggerFactory } from '../logger_factory'; +import { LoggingConfig } from '../logging_config'; +import { LoggingService } from '../logging_service'; + +const createConfig = () => { + return new LoggingConfig({ + appenders: new Map(), + loggers: [], + root: { + appenders: ['default'], + level: 'info', + }, + }); +}; + +const getLastMockCallArgs = (mockFunction: jest.Mock<(config: LoggingConfig) => void>) => { + expect(mockFunction).toHaveBeenCalled(); + return mockFunction.mock.calls[mockFunction.mock.calls.length - 1]; +}; + +let factory: MutableLoggerFactory; +let service: LoggingService; +let updateConfigMock: jest.Mock<(config: LoggingConfig) => void>; + +beforeEach(() => { + factory = new MutableLoggerFactory({} as any); + updateConfigMock = jest.spyOn(factory, 'updateConfig').mockImplementation(() => { + // noop + }); + jest.spyOn(factory, 'close').mockImplementation(() => { + // noop + }); + + service = new LoggingService(factory); +}); + +test('`upgrade()` updates logging factory config.', () => { + expect(factory.updateConfig).not.toHaveBeenCalled(); + + const config = createConfig(); + const config$ = new BehaviorSubject(config); + + service.upgrade(config$.asObservable()); + + expect(updateConfigMock).toHaveBeenCalledTimes(1); + expect(getLastMockCallArgs(updateConfigMock)[0]).toBe(config); + + const newConfig = createConfig(); + config$.next(newConfig); + expect(updateConfigMock).toHaveBeenCalledTimes(2); + expect(getLastMockCallArgs(updateConfigMock)[0]).toBe(newConfig); +}); + +test('`stop()` closes logger factory and stops config updates.', async () => { + const config$ = new BehaviorSubject(createConfig()); + + service.upgrade(config$.asObservable()); + updateConfigMock.mockReset(); + + await service.stop(); + + expect(factory.close).toHaveBeenCalled(); + + config$.next(createConfig()); + expect(updateConfigMock).not.toHaveBeenCalled(); +}); diff --git a/src/core/server/logging/appenders/__tests__/__snapshots__/appenders.test.ts.snap b/src/core/server/logging/appenders/__tests__/__snapshots__/appenders.test.ts.snap new file mode 100644 index 00000000000000..e7e8f70b345c76 --- /dev/null +++ b/src/core/server/logging/appenders/__tests__/__snapshots__/appenders.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`\`create()\` fails to create legacy appender if kbnServer is not provided. 1`] = `"Legacy appender requires kbnServer."`; diff --git a/src/core/server/logging/appenders/__tests__/appenders.test.ts b/src/core/server/logging/appenders/__tests__/appenders.test.ts new file mode 100644 index 00000000000000..0e4f83378cb18e --- /dev/null +++ b/src/core/server/logging/appenders/__tests__/appenders.test.ts @@ -0,0 +1,122 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const mockCreateLayout = jest.fn(); +jest.mock('../../layouts/layouts', () => { + const { schema } = require('../../../config/schema'); + return { + Layouts: { + configSchema: schema.object({ + kind: schema.literal('mock'), + }), + create: mockCreateLayout, + }, + }; +}); + +import { LegacyAppender } from '../../../legacy_compat/logging/appenders/legacy_appender'; +import { Appenders } from '../appenders'; +import { ConsoleAppender } from '../console/console_appender'; +import { FileAppender } from '../file/file_appender'; + +beforeEach(() => { + mockCreateLayout.mockReset(); +}); + +test('`configSchema` creates correct schema.', () => { + const appendersSchema = Appenders.configSchema; + const validConfig1 = { kind: 'file', layout: { kind: 'mock' }, path: 'path' }; + expect(appendersSchema.validate(validConfig1)).toEqual({ + kind: 'file', + layout: { kind: 'mock' }, + path: 'path', + }); + + const validConfig2 = { kind: 'console', layout: { kind: 'mock' } }; + expect(appendersSchema.validate(validConfig2)).toEqual({ + kind: 'console', + layout: { kind: 'mock' }, + }); + + const wrongConfig1 = { + kind: 'console', + layout: { kind: 'mock' }, + path: 'path', + }; + expect(() => appendersSchema.validate(wrongConfig1)).toThrow(); + + const wrongConfig2 = { kind: 'file', layout: { kind: 'mock' } }; + expect(() => appendersSchema.validate(wrongConfig2)).toThrow(); + + const wrongConfig3 = { + kind: 'console', + layout: { kind: 'mock' }, + path: 'path', + }; + expect(() => appendersSchema.validate(wrongConfig3)).toThrow(); +}); + +test('`create()` creates correct appender.', () => { + mockCreateLayout.mockReturnValue({ format: () => '' }); + + const consoleAppender = Appenders.create( + { + kind: 'console', + layout: { + highlight: true, + kind: 'pattern', + pattern: '', + }, + }, + {} as any + ); + expect(consoleAppender).toBeInstanceOf(ConsoleAppender); + + const fileAppender = Appenders.create( + { + kind: 'file', + layout: { + highlight: true, + kind: 'pattern', + pattern: '', + }, + path: 'path', + }, + {} as any + ); + expect(fileAppender).toBeInstanceOf(FileAppender); +}); + +test('`create()` fails to create legacy appender if kbnServer is not provided.', () => { + expect(() => { + Appenders.create({ kind: 'legacy-appender' }, { + getLegacyKbnServer() { + // noop + }, + } as any); + }).toThrowErrorMatchingSnapshot(); +}); + +test('`create()` creates legacy appender if kbnServer is provided.', () => { + const legacyAppender = Appenders.create({ kind: 'legacy-appender' }, { + getLegacyKbnServer: () => ({}), + } as any); + + expect(legacyAppender).toBeInstanceOf(LegacyAppender); +}); diff --git a/src/core/server/logging/appenders/__tests__/buffer_appender.test.ts b/src/core/server/logging/appenders/__tests__/buffer_appender.test.ts new file mode 100644 index 00000000000000..cdf2714f44e298 --- /dev/null +++ b/src/core/server/logging/appenders/__tests__/buffer_appender.test.ts @@ -0,0 +1,72 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LogLevel } from '../../log_level'; +import { LogRecord } from '../../log_record'; +import { BufferAppender } from '../buffer/buffer_appender'; + +test('`flush()` does not return any record buffered at the beginning.', () => { + const appender = new BufferAppender(); + + expect(appender.flush()).toHaveLength(0); +}); + +test('`flush()` returns all appended records and cleans internal buffer.', () => { + const records: LogRecord[] = [ + { + context: 'context-1', + level: LogLevel.All, + message: 'message-1', + timestamp: new Date(), + }, + { + context: 'context-2', + level: LogLevel.Trace, + message: 'message-2', + timestamp: new Date(), + }, + ]; + + const appender = new BufferAppender(); + + for (const record of records) { + appender.append(record); + } + + const flushedRecords = appender.flush(); + for (const record of records) { + expect(flushedRecords).toContainEqual(record); + } + expect(flushedRecords).toHaveLength(records.length); + expect(appender.flush()).toHaveLength(0); +}); + +test('`dispose()` flushes internal buffer.', async () => { + const appender = new BufferAppender(); + appender.append({ + context: 'context-1', + level: LogLevel.All, + message: 'message-1', + timestamp: new Date(), + }); + + await appender.dispose(); + + expect(appender.flush()).toHaveLength(0); +}); diff --git a/src/core/server/logging/appenders/__tests__/console_appender.test.ts b/src/core/server/logging/appenders/__tests__/console_appender.test.ts new file mode 100644 index 00000000000000..35128bd6ba1fdb --- /dev/null +++ b/src/core/server/logging/appenders/__tests__/console_appender.test.ts @@ -0,0 +1,89 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('../../layouts/layouts', () => { + const { schema } = require('../../../config/schema'); + return { + Layouts: { + configSchema: schema.object({ + kind: schema.literal('mock'), + }), + }, + }; +}); + +import { LogLevel } from '../../log_level'; +import { LogRecord } from '../../log_record'; +import { ConsoleAppender } from '../console/console_appender'; + +test('`configSchema` creates correct schema.', () => { + const appenderSchema = ConsoleAppender.configSchema; + const validConfig = { kind: 'console', layout: { kind: 'mock' } }; + expect(appenderSchema.validate(validConfig)).toEqual({ + kind: 'console', + layout: { kind: 'mock' }, + }); + + const wrongConfig1 = { kind: 'not-console', layout: { kind: 'mock' } }; + expect(() => appenderSchema.validate(wrongConfig1)).toThrow(); + + const wrongConfig2 = { kind: 'file', layout: { kind: 'mock' }, path: 'path' }; + expect(() => appenderSchema.validate(wrongConfig2)).toThrow(); +}); + +test('`append()` correctly formats records and pushes them to console.', () => { + jest.spyOn(global.console, 'log').mockImplementation(() => { + // noop + }); + + const records: LogRecord[] = [ + { + context: 'context-1', + level: LogLevel.All, + message: 'message-1', + timestamp: new Date(), + }, + { + context: 'context-2', + level: LogLevel.Trace, + message: 'message-2', + timestamp: new Date(), + }, + { + context: 'context-3', + error: new Error('Error'), + level: LogLevel.Fatal, + message: 'message-3', + timestamp: new Date(), + }, + ]; + + const appender = new ConsoleAppender({ + format(record) { + return `mock-${JSON.stringify(record)}`; + }, + }); + + for (const record of records) { + appender.append(record); + expect(console.log).toHaveBeenCalledWith(`mock-${JSON.stringify(record)}`); + } + + expect(console.log).toHaveBeenCalledTimes(records.length); +}); diff --git a/src/core/server/logging/appenders/__tests__/file_appender.test.ts b/src/core/server/logging/appenders/__tests__/file_appender.test.ts new file mode 100644 index 00000000000000..69b4980dff1f01 --- /dev/null +++ b/src/core/server/logging/appenders/__tests__/file_appender.test.ts @@ -0,0 +1,185 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('../../layouts/layouts', () => { + const { schema } = require('../../../config/schema'); + return { + Layouts: { + configSchema: schema.object({ + kind: schema.literal('mock'), + }), + }, + }; +}); + +const mockCreateWriteStream = jest.fn(); +jest.mock('fs', () => ({ createWriteStream: mockCreateWriteStream })); + +import { LogLevel } from '../../log_level'; +import { LogRecord } from '../../log_record'; +import { FileAppender } from '../file/file_appender'; + +const tickMs = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +beforeEach(() => { + mockCreateWriteStream.mockReset(); +}); + +test('`createConfigSchema()` creates correct schema.', () => { + const appenderSchema = FileAppender.configSchema; + + const validConfig = { kind: 'file', layout: { kind: 'mock' }, path: 'path' }; + expect(appenderSchema.validate(validConfig)).toEqual({ + kind: 'file', + layout: { kind: 'mock' }, + path: 'path', + }); + + const wrongConfig1 = { + kind: 'not-file', + layout: { kind: 'mock' }, + path: 'path', + }; + expect(() => appenderSchema.validate(wrongConfig1)).toThrow(); + + const wrongConfig2 = { kind: 'file', layout: { kind: 'mock' } }; + expect(() => appenderSchema.validate(wrongConfig2)).toThrow(); + + const wrongConfig3 = { kind: 'console', layout: { kind: 'mock' } }; + expect(() => appenderSchema.validate(wrongConfig3)).toThrow(); +}); + +test('file stream is created only once and only after first `append()` is called.', () => { + mockCreateWriteStream.mockReturnValue({ + write() { + // noop + }, + }); + + const mockPath = 'mock://path/file.log'; + const appender = new FileAppender({ format: () => '' }, mockPath); + + expect(mockCreateWriteStream).not.toHaveBeenCalled(); + + appender.append({ + context: 'context-1', + level: LogLevel.All, + message: 'message-1', + timestamp: new Date(), + }); + + expect(mockCreateWriteStream).toHaveBeenCalledTimes(1); + expect(mockCreateWriteStream).toHaveBeenCalledWith(mockPath, { + encoding: 'utf8', + flags: 'a', + }); + + mockCreateWriteStream.mockClear(); + appender.append({ + context: 'context-2', + level: LogLevel.All, + message: 'message-2', + timestamp: new Date(), + }); + + expect(mockCreateWriteStream).not.toHaveBeenCalled(); +}); + +test('`append()` correctly formats records and pushes them to the file.', () => { + const mockStreamWrite = jest.fn(); + mockCreateWriteStream.mockReturnValue({ write: mockStreamWrite }); + + const records: LogRecord[] = [ + { + context: 'context-1', + level: LogLevel.All, + message: 'message-1', + timestamp: new Date(), + }, + { + context: 'context-2', + level: LogLevel.Trace, + message: 'message-2', + timestamp: new Date(), + }, + { + context: 'context-3', + error: new Error('Error'), + level: LogLevel.Fatal, + message: 'message-3', + timestamp: new Date(), + }, + ]; + + const appender = new FileAppender( + { + format(record) { + return `mock-${JSON.stringify(record)}`; + }, + }, + 'mock://path/file.log' + ); + + for (const record of records) { + appender.append(record); + expect(mockStreamWrite).toHaveBeenCalledWith(`mock-${JSON.stringify(record)}\n`); + } + + expect(mockStreamWrite).toHaveBeenCalledTimes(records.length); +}); + +test('`dispose()` succeeds even if stream is not created.', async () => { + const appender = new FileAppender({ format: () => '' }, 'mock://path/file.log'); + + await appender.dispose(); +}); + +test('`dispose()` closes stream.', async () => { + const mockStreamEndFinished = jest.fn(); + const mockStreamEnd = jest.fn(async (chunk, encoding, callback) => { + // It's required to make sure `dispose` waits for `end` to complete. + await tickMs(100); + mockStreamEndFinished(); + callback(); + }); + + mockCreateWriteStream.mockReturnValue({ + end: mockStreamEnd, + write: () => { + // noop + }, + }); + + const appender = new FileAppender({ format: () => '' }, 'mock://path/file.log'); + appender.append({ + context: 'context-1', + level: LogLevel.All, + message: 'message-1', + timestamp: new Date(), + }); + + await appender.dispose(); + + expect(mockStreamEnd).toHaveBeenCalledTimes(1); + expect(mockStreamEnd).toHaveBeenCalledWith(undefined, undefined, expect.any(Function)); + expect(mockStreamEndFinished).toHaveBeenCalled(); + + // Consequent `dispose` calls should not fail even if stream has been disposed. + await appender.dispose(); +}); diff --git a/src/core/server/logging/appenders/appenders.ts b/src/core/server/logging/appenders/appenders.ts new file mode 100644 index 00000000000000..85806742e6fd24 --- /dev/null +++ b/src/core/server/logging/appenders/appenders.ts @@ -0,0 +1,87 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema, TypeOf } from '../../config/schema'; + +import { assertNever } from '../../../utils'; +import { Env } from '../../config'; +import { LegacyAppender } from '../../legacy_compat/logging/appenders/legacy_appender'; +import { Layouts } from '../layouts/layouts'; +import { LogRecord } from '../log_record'; +import { ConsoleAppender } from './console/console_appender'; +import { FileAppender } from './file/file_appender'; + +const appendersSchema = schema.oneOf([ + ConsoleAppender.configSchema, + FileAppender.configSchema, + LegacyAppender.configSchema, +]); + +/** @internal */ +export type AppenderConfigType = TypeOf; + +/** + * Entity that can append `LogRecord` instances to file, stdout, memory or whatever + * is implemented internally. It's supposed to be used by `Logger`. + * @internal + */ +export interface Appender { + append(record: LogRecord): void; +} + +/** + * This interface should be additionally implemented by the `Appender`'s if they are supposed + * to be properly disposed. It's intentionally separated from `Appender` interface so that `Logger` + * that interacts with `Appender` doesn't have control over appender lifetime. + * @internal + */ +export interface DisposableAppender extends Appender { + dispose: () => void; +} + +/** @internal */ +export class Appenders { + public static configSchema = appendersSchema; + + /** + * Factory method that creates specific `Appender` instances based on the passed `config` parameter. + * @param config Configuration specific to a particular `Appender` implementation. + * @param env Current environment that is required by some appenders. + * @returns Fully constructed `Appender` instance. + */ + public static create(config: AppenderConfigType, env: Env): DisposableAppender { + switch (config.kind) { + case 'console': + return new ConsoleAppender(Layouts.create(config.layout)); + + case 'file': + return new FileAppender(Layouts.create(config.layout), config.path); + + case 'legacy-appender': + const legacyKbnServer = env.getLegacyKbnServer(); + if (legacyKbnServer === undefined) { + throw new Error('Legacy appender requires kbnServer.'); + } + return new LegacyAppender(legacyKbnServer); + + default: + return assertNever(config); + } + } +} diff --git a/src/core/server/logging/appenders/buffer/buffer_appender.ts b/src/core/server/logging/appenders/buffer/buffer_appender.ts new file mode 100644 index 00000000000000..7024d3e5d16df1 --- /dev/null +++ b/src/core/server/logging/appenders/buffer/buffer_appender.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LogRecord } from '../../log_record'; +import { DisposableAppender } from '../appenders'; + +/** + * Simple appender that just buffers `LogRecord` instances it receives. It is a *reserved* appender + * that can't be set via configuration file. + * @internal + */ +export class BufferAppender implements DisposableAppender { + /** + * List of the buffered `LogRecord` instances. + */ + private readonly buffer: LogRecord[] = []; + + /** + * Appends new `LogRecord` to the buffer. + * @param record `LogRecord` instance to add to the buffer. + */ + public append(record: LogRecord) { + this.buffer.push(record); + } + + /** + * Clears buffer and returns all records that it had. + */ + public flush() { + return this.buffer.splice(0, this.buffer.length); + } + + /** + * Disposes `BufferAppender` and clears internal `LogRecord` buffer. + */ + public async dispose() { + this.flush(); + } +} diff --git a/src/core/server/logging/appenders/console/console_appender.ts b/src/core/server/logging/appenders/console/console_appender.ts new file mode 100644 index 00000000000000..63d78496bbaad7 --- /dev/null +++ b/src/core/server/logging/appenders/console/console_appender.ts @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../../../config/schema'; + +import { Layout, Layouts } from '../../layouts/layouts'; +import { LogRecord } from '../../log_record'; +import { DisposableAppender } from '../appenders'; + +const { literal, object } = schema; + +/** + * Appender that formats all the `LogRecord` instances it receives and logs them via built-in `console`. + * @internal + */ +export class ConsoleAppender implements DisposableAppender { + public static configSchema = object({ + kind: literal('console'), + layout: Layouts.configSchema, + }); + + /** + * Creates ConsoleAppender instance. + * @param layout Instance of `Layout` sub-class responsible for `LogRecord` formatting. + */ + constructor(private readonly layout: Layout) {} + + /** + * Formats specified `record` and logs it via built-in `console`. + * @param record `LogRecord` instance to be logged. + */ + public append(record: LogRecord) { + // tslint:disable no-console + console.log(this.layout.format(record)); + } + + /** + * Disposes `ConsoleAppender`. + */ + public dispose() { + // noop + } +} diff --git a/src/core/server/logging/appenders/file/file_appender.ts b/src/core/server/logging/appenders/file/file_appender.ts new file mode 100644 index 00000000000000..ac8c18bc72c811 --- /dev/null +++ b/src/core/server/logging/appenders/file/file_appender.ts @@ -0,0 +1,80 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createWriteStream, WriteStream } from 'fs'; +import { schema } from '../../../config/schema'; + +import { Layout, Layouts } from '../../layouts/layouts'; +import { LogRecord } from '../../log_record'; +import { DisposableAppender } from '../appenders'; + +/** + * Appender that formats all the `LogRecord` instances it receives and writes them to the specified file. + * @internal + */ +export class FileAppender implements DisposableAppender { + public static configSchema = schema.object({ + kind: schema.literal('file'), + layout: Layouts.configSchema, + path: schema.string(), + }); + + /** + * Writable file stream to write formatted `LogRecord` to. + */ + private outputStream?: WriteStream; + + /** + * Creates FileAppender instance with specified layout and file path. + * @param layout Instance of `Layout` sub-class responsible for `LogRecord` formatting. + * @param path Path to the file where log records should be stored. + */ + constructor(private readonly layout: Layout, private readonly path: string) {} + + /** + * Formats specified `record` and writes them to the specified file. + * @param record `LogRecord` instance to be logged. + */ + public append(record: LogRecord) { + if (this.outputStream === undefined) { + this.outputStream = createWriteStream(this.path, { + encoding: 'utf8', + flags: 'a', + }); + } + + this.outputStream.write(`${this.layout.format(record)}\n`); + } + + /** + * Disposes `FileAppender`. Waits for the underlying file stream to be completely flushed and closed. + */ + public async dispose() { + await new Promise(resolve => { + if (this.outputStream === undefined) { + return resolve(); + } + + this.outputStream.end(undefined, undefined, () => { + this.outputStream = undefined; + resolve(); + }); + }); + } +} diff --git a/src/core/server/logging/index.ts b/src/core/server/logging/index.ts new file mode 100644 index 00000000000000..2ecb73899935ed --- /dev/null +++ b/src/core/server/logging/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { Logger } from './logger'; +export { LoggerFactory } from './logger_factory'; diff --git a/src/core/server/logging/layouts/__tests__/__snapshots__/json_layout.test.ts.snap b/src/core/server/logging/layouts/__tests__/__snapshots__/json_layout.test.ts.snap new file mode 100644 index 00000000000000..d95c3893aa3d8e --- /dev/null +++ b/src/core/server/logging/layouts/__tests__/__snapshots__/json_layout.test.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`\`format()\` correctly formats record. 1`] = `"{\\"@timestamp\\":\\"2012-02-01T00:00:00.000Z\\",\\"context\\":\\"context-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"name\\":\\"Some error name\\",\\"stack\\":\\"Some error stack\\"},\\"level\\":\\"FATAL\\",\\"message\\":\\"message-1\\"}"`; + +exports[`\`format()\` correctly formats record. 2`] = `"{\\"@timestamp\\":\\"2012-02-01T00:00:00.000Z\\",\\"context\\":\\"context-2\\",\\"level\\":\\"ERROR\\",\\"message\\":\\"message-2\\"}"`; + +exports[`\`format()\` correctly formats record. 3`] = `"{\\"@timestamp\\":\\"2012-02-01T00:00:00.000Z\\",\\"context\\":\\"context-3\\",\\"level\\":\\"WARN\\",\\"message\\":\\"message-3\\"}"`; + +exports[`\`format()\` correctly formats record. 4`] = `"{\\"@timestamp\\":\\"2012-02-01T00:00:00.000Z\\",\\"context\\":\\"context-4\\",\\"level\\":\\"DEBUG\\",\\"message\\":\\"message-4\\"}"`; + +exports[`\`format()\` correctly formats record. 5`] = `"{\\"@timestamp\\":\\"2012-02-01T00:00:00.000Z\\",\\"context\\":\\"context-5\\",\\"level\\":\\"INFO\\",\\"message\\":\\"message-5\\"}"`; + +exports[`\`format()\` correctly formats record. 6`] = `"{\\"@timestamp\\":\\"2012-02-01T00:00:00.000Z\\",\\"context\\":\\"context-6\\",\\"level\\":\\"TRACE\\",\\"message\\":\\"message-6\\"}"`; diff --git a/src/core/server/logging/layouts/__tests__/__snapshots__/pattern_layout.test.ts.snap b/src/core/server/logging/layouts/__tests__/__snapshots__/pattern_layout.test.ts.snap new file mode 100644 index 00000000000000..b727fc3c478ff7 --- /dev/null +++ b/src/core/server/logging/layouts/__tests__/__snapshots__/pattern_layout.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`\`format()\` correctly formats record with custom pattern. 1`] = `"mock-Some error stack-context-1-Some error stack"`; + +exports[`\`format()\` correctly formats record with custom pattern. 2`] = `"mock-message-2-context-2-message-2"`; + +exports[`\`format()\` correctly formats record with custom pattern. 3`] = `"mock-message-3-context-3-message-3"`; + +exports[`\`format()\` correctly formats record with custom pattern. 4`] = `"mock-message-4-context-4-message-4"`; + +exports[`\`format()\` correctly formats record with custom pattern. 5`] = `"mock-message-5-context-5-message-5"`; + +exports[`\`format()\` correctly formats record with custom pattern. 6`] = `"mock-message-6-context-6-message-6"`; + +exports[`\`format()\` correctly formats record with full pattern. 1`] = `"[2012-02-01T00:00:00.000Z][FATAL][context-1] Some error stack"`; + +exports[`\`format()\` correctly formats record with full pattern. 2`] = `"[2012-02-01T00:00:00.000Z][ERROR][context-2] message-2"`; + +exports[`\`format()\` correctly formats record with full pattern. 3`] = `"[2012-02-01T00:00:00.000Z][WARN ][context-3] message-3"`; + +exports[`\`format()\` correctly formats record with full pattern. 4`] = `"[2012-02-01T00:00:00.000Z][DEBUG][context-4] message-4"`; + +exports[`\`format()\` correctly formats record with full pattern. 5`] = `"[2012-02-01T00:00:00.000Z][INFO ][context-5] message-5"`; + +exports[`\`format()\` correctly formats record with full pattern. 6`] = `"[2012-02-01T00:00:00.000Z][TRACE][context-6] message-6"`; + +exports[`\`format()\` correctly formats record with highlighting. 1`] = `"[2012-02-01T00:00:00.000Z][FATAL][context-1] Some error stack"`; + +exports[`\`format()\` correctly formats record with highlighting. 2`] = `"[2012-02-01T00:00:00.000Z][ERROR][context-2] message-2"`; + +exports[`\`format()\` correctly formats record with highlighting. 3`] = `"[2012-02-01T00:00:00.000Z][WARN ][context-3] message-3"`; + +exports[`\`format()\` correctly formats record with highlighting. 4`] = `"[2012-02-01T00:00:00.000Z][DEBUG][context-4] message-4"`; + +exports[`\`format()\` correctly formats record with highlighting. 5`] = `"[2012-02-01T00:00:00.000Z][INFO ][context-5] message-5"`; + +exports[`\`format()\` correctly formats record with highlighting. 6`] = `"[2012-02-01T00:00:00.000Z][TRACE][context-6] message-6"`; diff --git a/src/core/server/logging/layouts/__tests__/json_layout.test.ts b/src/core/server/logging/layouts/__tests__/json_layout.test.ts new file mode 100644 index 00000000000000..ec94d023b2d64f --- /dev/null +++ b/src/core/server/logging/layouts/__tests__/json_layout.test.ts @@ -0,0 +1,80 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LogLevel } from '../../log_level'; +import { LogRecord } from '../../log_record'; +import { JsonLayout } from '../json_layout'; + +const records: LogRecord[] = [ + { + context: 'context-1', + error: { + message: 'Some error message', + name: 'Some error name', + stack: 'Some error stack', + }, + level: LogLevel.Fatal, + message: 'message-1', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, + { + context: 'context-2', + level: LogLevel.Error, + message: 'message-2', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, + { + context: 'context-3', + level: LogLevel.Warn, + message: 'message-3', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, + { + context: 'context-4', + level: LogLevel.Debug, + message: 'message-4', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, + { + context: 'context-5', + level: LogLevel.Info, + message: 'message-5', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, + { + context: 'context-6', + level: LogLevel.Trace, + message: 'message-6', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, +]; + +test('`createConfigSchema()` creates correct schema.', () => { + const layoutSchema = JsonLayout.configSchema; + + expect(layoutSchema.validate({ kind: 'json' })).toEqual({ kind: 'json' }); +}); + +test('`format()` correctly formats record.', () => { + const layout = new JsonLayout(); + + for (const record of records) { + expect(layout.format(record)).toMatchSnapshot(); + } +}); diff --git a/src/core/server/logging/layouts/__tests__/layouts.test.ts b/src/core/server/logging/layouts/__tests__/layouts.test.ts new file mode 100644 index 00000000000000..ca70710233fee3 --- /dev/null +++ b/src/core/server/logging/layouts/__tests__/layouts.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { JsonLayout } from '../json_layout'; +import { Layouts } from '../layouts'; +import { PatternLayout } from '../pattern_layout'; + +test('`configSchema` creates correct schema for `pattern` layout.', () => { + const layoutsSchema = Layouts.configSchema; + const validConfigWithOptional = { kind: 'pattern' }; + expect(layoutsSchema.validate(validConfigWithOptional)).toEqual({ + highlight: undefined, + kind: 'pattern', + pattern: undefined, + }); + + const validConfig = { + highlight: true, + kind: 'pattern', + pattern: '{message}', + }; + expect(layoutsSchema.validate(validConfig)).toEqual({ + highlight: true, + kind: 'pattern', + pattern: '{message}', + }); + + const wrongConfig2 = { kind: 'pattern', pattern: 1 }; + expect(() => layoutsSchema.validate(wrongConfig2)).toThrow(); +}); + +test('`createConfigSchema()` creates correct schema for `json` layout.', () => { + const layoutsSchema = Layouts.configSchema; + + const validConfig = { kind: 'json' }; + expect(layoutsSchema.validate(validConfig)).toEqual({ kind: 'json' }); +}); + +test('`create()` creates correct layout.', () => { + const patternLayout = Layouts.create({ + highlight: false, + kind: 'pattern', + pattern: '[{timestamp}][{level}][{context}] {message}', + }); + expect(patternLayout).toBeInstanceOf(PatternLayout); + + const jsonLayout = Layouts.create({ kind: 'json' }); + expect(jsonLayout).toBeInstanceOf(JsonLayout); +}); diff --git a/src/core/server/logging/layouts/__tests__/pattern_layout.test.ts b/src/core/server/logging/layouts/__tests__/pattern_layout.test.ts new file mode 100644 index 00000000000000..4e6ddf2c097ed1 --- /dev/null +++ b/src/core/server/logging/layouts/__tests__/pattern_layout.test.ts @@ -0,0 +1,121 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { stripAnsiSnapshotSerializer } from '../../../../test_helpers/strip_ansi_snapshot_serializer'; +import { LogLevel } from '../../log_level'; +import { LogRecord } from '../../log_record'; +import { PatternLayout } from '../pattern_layout'; + +const records: LogRecord[] = [ + { + context: 'context-1', + error: { + message: 'Some error message', + name: 'Some error name', + stack: 'Some error stack', + }, + level: LogLevel.Fatal, + message: 'message-1', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, + { + context: 'context-2', + level: LogLevel.Error, + message: 'message-2', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, + { + context: 'context-3', + level: LogLevel.Warn, + message: 'message-3', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, + { + context: 'context-4', + level: LogLevel.Debug, + message: 'message-4', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, + { + context: 'context-5', + level: LogLevel.Info, + message: 'message-5', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, + { + context: 'context-6', + level: LogLevel.Trace, + message: 'message-6', + timestamp: new Date(Date.UTC(2012, 1, 1)), + }, +]; + +expect.addSnapshotSerializer(stripAnsiSnapshotSerializer); + +test('`createConfigSchema()` creates correct schema.', () => { + const layoutSchema = PatternLayout.configSchema; + + const validConfigWithOptional = { kind: 'pattern' }; + expect(layoutSchema.validate(validConfigWithOptional)).toEqual({ + highlight: undefined, + kind: 'pattern', + pattern: undefined, + }); + + const validConfig = { + highlight: true, + kind: 'pattern', + pattern: '{message}', + }; + expect(layoutSchema.validate(validConfig)).toEqual({ + highlight: true, + kind: 'pattern', + pattern: '{message}', + }); + + const wrongConfig1 = { kind: 'json' }; + expect(() => layoutSchema.validate(wrongConfig1)).toThrow(); + + const wrongConfig2 = { kind: 'pattern', pattern: 1 }; + expect(() => layoutSchema.validate(wrongConfig2)).toThrow(); +}); + +test('`format()` correctly formats record with full pattern.', () => { + const layout = new PatternLayout(); + + for (const record of records) { + expect(layout.format(record)).toMatchSnapshot(); + } +}); + +test('`format()` correctly formats record with custom pattern.', () => { + const layout = new PatternLayout('mock-{message}-{context}-{message}'); + + for (const record of records) { + expect(layout.format(record)).toMatchSnapshot(); + } +}); + +test('`format()` correctly formats record with highlighting.', () => { + const layout = new PatternLayout(undefined, true); + + for (const record of records) { + expect(layout.format(record)).toMatchSnapshot(); + } +}); diff --git a/src/core/server/logging/layouts/json_layout.ts b/src/core/server/logging/layouts/json_layout.ts new file mode 100644 index 00000000000000..7983cfc992284d --- /dev/null +++ b/src/core/server/logging/layouts/json_layout.ts @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema, TypeOf } from '../../config/schema'; + +import { LogRecord } from '../log_record'; +import { Layout } from './layouts'; + +const { literal, object } = schema; + +const jsonLayoutSchema = object({ + kind: literal('json'), +}); + +/** @internal */ +export type JsonLayoutConfigType = TypeOf; + +/** + * Layout that just converts `LogRecord` into JSON string. + * @internal + */ +export class JsonLayout implements Layout { + public static configSchema = jsonLayoutSchema; + + private static errorToSerializableObject(error: Error | undefined) { + if (error === undefined) { + return error; + } + + return { + message: error.message, + name: error.name, + stack: error.stack, + }; + } + + public format(record: LogRecord): string { + return JSON.stringify({ + '@timestamp': record.timestamp.toISOString(), + context: record.context, + error: JsonLayout.errorToSerializableObject(record.error), + level: record.level.id.toUpperCase(), + message: record.message, + meta: record.meta, + }); + } +} diff --git a/src/core/server/logging/layouts/layouts.ts b/src/core/server/logging/layouts/layouts.ts new file mode 100644 index 00000000000000..85726aaf609030 --- /dev/null +++ b/src/core/server/logging/layouts/layouts.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../../config/schema'; + +import { assertNever } from '../../../utils'; +import { LogRecord } from '../log_record'; +import { JsonLayout, JsonLayoutConfigType } from './json_layout'; +import { PatternLayout, PatternLayoutConfigType } from './pattern_layout'; + +const { oneOf } = schema; + +type LayoutConfigType = PatternLayoutConfigType | JsonLayoutConfigType; + +/** + * Entity that can format `LogRecord` instance into a string. + * @internal + */ +export interface Layout { + format(record: LogRecord): string; +} + +/** @internal */ +export class Layouts { + public static configSchema = oneOf([JsonLayout.configSchema, PatternLayout.configSchema]); + + /** + * Factory method that creates specific `Layout` instances based on the passed `config` parameter. + * @param config Configuration specific to a particular `Layout` implementation. + * @returns Fully constructed `Layout` instance. + */ + public static create(config: LayoutConfigType): Layout { + switch (config.kind) { + case 'json': + return new JsonLayout(); + + case 'pattern': + return new PatternLayout(config.pattern, config.highlight); + + default: + return assertNever(config); + } + } +} diff --git a/src/core/server/logging/layouts/pattern_layout.ts b/src/core/server/logging/layouts/pattern_layout.ts new file mode 100644 index 00000000000000..5f21328e6c7db4 --- /dev/null +++ b/src/core/server/logging/layouts/pattern_layout.ts @@ -0,0 +1,116 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import chalk from 'chalk'; +import { schema, TypeOf } from '../../config/schema'; + +import { LogLevel } from '../log_level'; +import { LogRecord } from '../log_record'; +import { Layout } from './layouts'; + +/** + * A set of static constants describing supported parameters in the log message pattern. + */ +const Parameters = Object.freeze({ + Context: '{context}', + Level: '{level}', + Message: '{message}', + Timestamp: '{timestamp}', +}); + +/** + * Regular expression used to parse log message pattern and fill in placeholders + * with the actual data. + */ +const PATTERN_REGEX = new RegExp( + `${Parameters.Timestamp}|${Parameters.Level}|${Parameters.Context}|${Parameters.Message}`, + 'gi' +); + +/** + * Mapping between `LogLevel` and color that is used to highlight `level` part of + * the log message. + */ +const LEVEL_COLORS = new Map([ + [LogLevel.Fatal, chalk.red], + [LogLevel.Error, chalk.red], + [LogLevel.Warn, chalk.yellow], + [LogLevel.Debug, chalk.green], + [LogLevel.Trace, chalk.blue], +]); + +/** + * Default pattern used by PatternLayout if it's not overridden in the configuration. + */ +const DEFAULT_PATTERN = `[${Parameters.Timestamp}][${Parameters.Level}][${Parameters.Context}] ${ + Parameters.Message +}`; + +const patternLayoutSchema = schema.object({ + highlight: schema.maybe(schema.boolean()), + kind: schema.literal('pattern'), + pattern: schema.maybe(schema.string()), +}); + +/** @internal */ +export type PatternLayoutConfigType = TypeOf; + +/** + * Layout that formats `LogRecord` using the `pattern` string with optional + * color highlighting (eg. to make log messages easier to read in the terminal). + * @internal + */ +export class PatternLayout implements Layout { + public static configSchema = patternLayoutSchema; + + private static highlightRecord(record: LogRecord, formattedRecord: Map) { + if (LEVEL_COLORS.has(record.level)) { + const color = LEVEL_COLORS.get(record.level)!; + formattedRecord.set(Parameters.Level, color(formattedRecord.get(Parameters.Level)!)); + } + + formattedRecord.set( + Parameters.Context, + chalk.magenta(formattedRecord.get(Parameters.Context)!) + ); + } + + constructor(private readonly pattern = DEFAULT_PATTERN, private readonly highlight = false) {} + + /** + * Formats `LogRecord` into a string based on the specified `pattern` and `highlighting` options. + * @param record Instance of `LogRecord` to format into string. + */ + public format(record: LogRecord): string { + // Error stack is much more useful than just the message. + const message = (record.error && record.error.stack) || record.message; + const formattedRecord = new Map([ + [Parameters.Timestamp, record.timestamp.toISOString()], + [Parameters.Level, record.level.id.toUpperCase().padEnd(5)], + [Parameters.Context, record.context], + [Parameters.Message, message], + ]); + + if (this.highlight) { + PatternLayout.highlightRecord(record, formattedRecord); + } + + return this.pattern.replace(PATTERN_REGEX, match => formattedRecord.get(match)!); + } +} diff --git a/src/core/server/logging/log_level.ts b/src/core/server/logging/log_level.ts new file mode 100644 index 00000000000000..b4fe529e9857b5 --- /dev/null +++ b/src/core/server/logging/log_level.ts @@ -0,0 +1,81 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { assertNever } from '../../utils'; + +/** + * Possible log level string values. + * @internal + */ +export type LogLevelId = 'all' | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'off'; + +/** + * Represents the log level, manages string -> `LogLevel` conversion and comparison of log level + * priorities between themselves. + * @internal + */ +export class LogLevel { + public static readonly Off = new LogLevel('off', 1); + public static readonly Fatal = new LogLevel('fatal', 2); + public static readonly Error = new LogLevel('error', 3); + public static readonly Warn = new LogLevel('warn', 4); + public static readonly Info = new LogLevel('info', 5); + public static readonly Debug = new LogLevel('debug', 6); + public static readonly Trace = new LogLevel('trace', 7); + public static readonly All = new LogLevel('all', 8); + + /** + * Converts string representation of log level into `LogLevel` instance. + * @param level String representation of log level. + * @returns Instance of `LogLevel` class. + */ + public static fromId(level: LogLevelId): LogLevel { + switch (level) { + case 'all': + return LogLevel.All; + case 'fatal': + return LogLevel.Fatal; + case 'error': + return LogLevel.Error; + case 'warn': + return LogLevel.Warn; + case 'info': + return LogLevel.Info; + case 'debug': + return LogLevel.Debug; + case 'trace': + return LogLevel.Trace; + case 'off': + return LogLevel.Off; + default: + return assertNever(level); + } + } + + private constructor(readonly id: LogLevelId, readonly value: number) {} + + /** + * Indicates whether current log level covers the one that is passed as an argument. + * @param level Instance of `LogLevel` to compare to. + * @returns True if specified `level` is covered by this log level. + */ + public supports(level: LogLevel) { + return this.value >= level.value; + } +} diff --git a/src/core/server/logging/log_record.ts b/src/core/server/logging/log_record.ts new file mode 100644 index 00000000000000..e7f93f7fc3e144 --- /dev/null +++ b/src/core/server/logging/log_record.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LogLevel } from './log_level'; + +/** + * Essential parts of every log message. + * @internal + */ +export interface LogRecord { + timestamp: Date; + level: LogLevel; + context: string; + message: string; + error?: Error; + meta?: { [name: string]: any }; +} diff --git a/src/core/server/logging/logger.ts b/src/core/server/logging/logger.ts new file mode 100644 index 00000000000000..1298fdd9030f56 --- /dev/null +++ b/src/core/server/logging/logger.ts @@ -0,0 +1,114 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Appender } from './appenders/appenders'; +import { LogLevel } from './log_level'; +import { LogRecord } from './log_record'; + +export interface LogMeta { + [key: string]: any; +} + +/** + * Logger exposes all the necessary methods to log any type of information and + * this is the interface used by the logging consumers including plugins. + */ +export interface Logger { + trace(message: string, meta?: LogMeta): void; + debug(message: string, meta?: LogMeta): void; + info(message: string, meta?: LogMeta): void; + warn(errorOrMessage: string | Error, meta?: LogMeta): void; + error(errorOrMessage: string | Error, meta?: LogMeta): void; + fatal(errorOrMessage: string | Error, meta?: LogMeta): void; + + /** @internal */ + log(record: LogRecord): void; +} + +function isError(x: any): x is Error { + return x instanceof Error; +} + +/** @internal */ +export class BaseLogger implements Logger { + constructor( + private readonly context: string, + private readonly level: LogLevel, + private readonly appenders: Appender[] + ) {} + + public trace(message: string, meta?: LogMeta): void { + this.log(this.createLogRecord(LogLevel.Trace, message, meta)); + } + + public debug(message: string, meta?: LogMeta): void { + this.log(this.createLogRecord(LogLevel.Debug, message, meta)); + } + + public info(message: string, meta?: LogMeta): void { + this.log(this.createLogRecord(LogLevel.Info, message, meta)); + } + + public warn(errorOrMessage: string | Error, meta?: LogMeta): void { + this.log(this.createLogRecord(LogLevel.Warn, errorOrMessage, meta)); + } + + public error(errorOrMessage: string | Error, meta?: LogMeta): void { + this.log(this.createLogRecord(LogLevel.Error, errorOrMessage, meta)); + } + + public fatal(errorOrMessage: string | Error, meta?: LogMeta): void { + this.log(this.createLogRecord(LogLevel.Fatal, errorOrMessage, meta)); + } + + public log(record: LogRecord) { + if (!this.level.supports(record.level)) { + return; + } + + for (const appender of this.appenders) { + appender.append(record); + } + } + + private createLogRecord( + level: LogLevel, + errorOrMessage: string | Error, + meta?: LogMeta + ): LogRecord { + if (isError(errorOrMessage)) { + return { + context: this.context, + error: errorOrMessage, + level, + message: errorOrMessage.message, + meta, + timestamp: new Date(), + }; + } + + return { + context: this.context, + level, + message: errorOrMessage, + meta, + timestamp: new Date(), + }; + } +} diff --git a/src/core/server/logging/logger_adapter.ts b/src/core/server/logging/logger_adapter.ts new file mode 100644 index 00000000000000..ffc212631e7b42 --- /dev/null +++ b/src/core/server/logging/logger_adapter.ts @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LogRecord } from './log_record'; +import { Logger, LogMeta } from './logger'; + +/** @internal */ +export class LoggerAdapter implements Logger { + constructor(private logger: Logger) {} + + /** + * The current logger can be updated "on the fly", e.g. when the log config + * has changed. + * + * This is not intended for external use, only internally in Kibana + * + * @internal + */ + public updateLogger(logger: Logger) { + this.logger = logger; + } + + public trace(message: string, meta?: LogMeta): void { + this.logger.trace(message, meta); + } + + public debug(message: string, meta?: LogMeta): void { + this.logger.debug(message, meta); + } + + public info(message: string, meta?: LogMeta): void { + this.logger.info(message, meta); + } + + public warn(errorOrMessage: string | Error, meta?: LogMeta): void { + this.logger.warn(errorOrMessage, meta); + } + + public error(errorOrMessage: string | Error, meta?: LogMeta): void { + this.logger.error(errorOrMessage, meta); + } + + public fatal(errorOrMessage: string | Error, meta?: LogMeta): void { + this.logger.fatal(errorOrMessage, meta); + } + + public log(record: LogRecord) { + this.logger.log(record); + } +} diff --git a/src/core/server/logging/logger_factory.ts b/src/core/server/logging/logger_factory.ts new file mode 100644 index 00000000000000..a62a6c59a4d0e9 --- /dev/null +++ b/src/core/server/logging/logger_factory.ts @@ -0,0 +1,135 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Env } from '../config'; +import { Appenders, DisposableAppender } from './appenders/appenders'; +import { BufferAppender } from './appenders/buffer/buffer_appender'; +import { LogLevel } from './log_level'; +import { BaseLogger, Logger } from './logger'; +import { LoggerAdapter } from './logger_adapter'; +import { LoggerConfigType, LoggingConfig } from './logging_config'; + +/** + * The single purpose of `LoggerFactory` interface is to define a way to + * retrieve a context-based logger instance. + */ +export interface LoggerFactory { + /** + * Returns a `Logger` instance for the specified context. + * @param contextParts Parts of the context to return logger for. For example + * get('plugins', 'pid') will return a logger for the `plugins.pid` context. + */ + get(...contextParts: string[]): Logger; +} + +/** @internal */ +export class MutableLoggerFactory implements LoggerFactory { + private config?: LoggingConfig; + private readonly appenders: Map = new Map(); + private readonly bufferAppender = new BufferAppender(); + private readonly loggers: Map = new Map(); + + constructor(private readonly env: Env) {} + + public get(...contextParts: string[]): Logger { + const context = LoggingConfig.getLoggerContext(contextParts); + if (this.loggers.has(context)) { + return this.loggers.get(context)!; + } + + this.loggers.set(context, new LoggerAdapter(this.createLogger(context, this.config))); + + return this.loggers.get(context)!; + } + + /** + * Updates all current active loggers with the new config values. + * @param config New config instance. + */ + public updateConfig(config: LoggingConfig) { + // Config update is asynchronous and may require some time to complete, so we should invalidate + // config so that new loggers will be using BufferAppender until newly configured appenders are ready. + this.config = undefined; + + // Appenders must be reset, so we first dispose of the current ones, then + // build up a new set of appenders. + + for (const appender of this.appenders.values()) { + appender.dispose(); + } + this.appenders.clear(); + + for (const [appenderKey, appenderConfig] of config.appenders.entries()) { + this.appenders.set(appenderKey, Appenders.create(appenderConfig, this.env)); + } + + for (const [loggerKey, loggerAdapter] of this.loggers.entries()) { + loggerAdapter.updateLogger(this.createLogger(loggerKey, config)); + } + + this.config = config; + + // Re-log all buffered log records with newly configured appenders. + for (const logRecord of this.bufferAppender.flush()) { + this.get(logRecord.context).log(logRecord); + } + } + + /** + * Disposes all loggers (closes log files, clears buffers etc.). Factory is not usable after + * calling of this method. + * @returns Promise that is resolved once all loggers are successfully disposed. + */ + public async close() { + for (const appender of this.appenders.values()) { + await appender.dispose(); + } + + await this.bufferAppender.dispose(); + + this.appenders.clear(); + this.loggers.clear(); + } + + private createLogger(context: string, config: LoggingConfig | undefined) { + if (config === undefined) { + // If we don't have config yet, use `buffered` appender that will store all logged messages in the memory + // until the config is ready. + return new BaseLogger(context, LogLevel.All, [this.bufferAppender]); + } + + const { level, appenders } = this.getLoggerConfigByContext(config, context); + const loggerLevel = LogLevel.fromId(level); + const loggerAppenders = appenders.map(appenderKey => this.appenders.get(appenderKey)!); + + return new BaseLogger(context, loggerLevel, loggerAppenders); + } + + private getLoggerConfigByContext(config: LoggingConfig, context: string): LoggerConfigType { + const loggerConfig = config.loggers.get(context); + if (loggerConfig !== undefined) { + return loggerConfig; + } + + // If we don't have configuration for the specified context and it's the "nested" one (eg. `foo.bar.baz`), + // let's move up to the parent context (eg. `foo.bar`) and check if it has config we can rely on. Otherwise + // we fallback to the `root` context that should always be defined (enforced by configuration schema). + return this.getLoggerConfigByContext(config, LoggingConfig.getParentLoggerContext(context)); + } +} diff --git a/src/core/server/logging/logging_config.ts b/src/core/server/logging/logging_config.ts new file mode 100644 index 00000000000000..b23d32f5e9b3cd --- /dev/null +++ b/src/core/server/logging/logging_config.ts @@ -0,0 +1,206 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema, TypeOf } from '../config/schema'; +import { AppenderConfigType, Appenders } from './appenders/appenders'; + +// 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]; + +/** + * Separator string that used within nested context name (eg. plugins.pid). + */ +const CONTEXT_SEPARATOR = '.'; + +/** + * Name of the `root` context that always exists and sits at the top of logger hierarchy. + */ +const ROOT_CONTEXT_NAME = 'root'; + +/** + * Name of the appender that is always presented and used by `root` logger by default. + */ +const DEFAULT_APPENDER_NAME = 'default'; + +const createLevelSchema = schema.oneOf( + [ + schema.literal('all'), + schema.literal('fatal'), + schema.literal('error'), + schema.literal('warn'), + schema.literal('info'), + schema.literal('debug'), + schema.literal('trace'), + schema.literal('off'), + ], + { + defaultValue: 'info', + } +); + +const createLoggerSchema = schema.object({ + appenders: schema.arrayOf(schema.string(), { defaultValue: [] }), + context: schema.string(), + level: createLevelSchema, +}); + +const loggingSchema = schema.object({ + appenders: schema.mapOf(schema.string(), Appenders.configSchema, { + defaultValue: new Map(), + }), + loggers: schema.arrayOf(createLoggerSchema, { + defaultValue: [], + }), + root: schema.object({ + appenders: schema.arrayOf(schema.string(), { + defaultValue: [DEFAULT_APPENDER_NAME], + minSize: 1, + }), + level: createLevelSchema, + }), +}); + +/** @internal */ +export type LoggerConfigType = TypeOf; + +type LoggingConfigType = TypeOf; + +/** + * Describes the config used to fully setup logging subsystem. + * @internal + */ +export class LoggingConfig { + public static schema = loggingSchema; + + /** + * Helper method that joins separate string context parts into single context string. + * In case joined context is an empty string, `root` context name is returned. + * @param contextParts List of the context parts (e.g. ['parent', 'child']. + * @returns {string} Joined context string (e.g. 'parent.child'). + */ + public static getLoggerContext(contextParts: string[]) { + return contextParts.join(CONTEXT_SEPARATOR) || ROOT_CONTEXT_NAME; + } + + /** + * Helper method that returns parent context for the specified one. + * @param context Context to find parent for. + * @returns Name of the parent context or `root` if the context is the top level one. + */ + public static getParentLoggerContext(context: string) { + const lastIndexOfSeparator = context.lastIndexOf(CONTEXT_SEPARATOR); + if (lastIndexOfSeparator === -1) { + return ROOT_CONTEXT_NAME; + } + + return context.slice(0, lastIndexOfSeparator); + } + + /** + * Map of the appender unique arbitrary key and its corresponding config. + */ + public readonly appenders: Map = new Map([ + [ + DEFAULT_APPENDER_NAME, + { + kind: 'console', + layout: { kind: 'pattern', highlight: true }, + } as AppenderConfigType, + ], + ]); + + /** + * Map of the logger unique arbitrary key (context) and its corresponding config. + */ + public readonly loggers: Map = new Map(); + + constructor(configType: LoggingConfigType) { + this.fillAppendersConfig(configType); + this.fillLoggersConfig(configType); + } + + private fillAppendersConfig(loggingConfig: LoggingConfigType) { + for (const [appenderKey, appenderSchema] of loggingConfig.appenders) { + this.appenders.set(appenderKey, appenderSchema); + } + } + + private fillLoggersConfig(loggingConfig: LoggingConfigType) { + // Include `root` logger into common logger list so that it can easily be a part + // of the logger hierarchy and put all the loggers in map for easier retrieval. + const loggers = [ + { context: ROOT_CONTEXT_NAME, ...loggingConfig.root }, + ...loggingConfig.loggers, + ]; + + const loggerConfigByContext = new Map( + loggers.map(loggerConfig => toTuple(loggerConfig.context, loggerConfig)) + ); + + for (const [loggerContext, loggerConfig] of loggerConfigByContext) { + // Ensure logger config only contains valid appenders. + const unsupportedAppenderKey = loggerConfig.appenders.find( + appenderKey => !this.appenders.has(appenderKey) + ); + + if (unsupportedAppenderKey) { + throw new Error( + `Logger "${loggerContext}" contains unsupported appender key "${unsupportedAppenderKey}".` + ); + } + + const appenders = getAppenders(loggerConfig, loggerConfigByContext); + + // We expect `appenders` to never be empty at this point, since the `root` context config should always + // have at least one appender that is enforced by the config schema validation. + this.loggers.set(loggerContext, { + ...loggerConfig, + appenders, + }); + } + } +} + +/** + * Get appenders for logger config. + * + * If config for current context doesn't have any defined appenders inherit + * appenders from the parent context config. + */ +function getAppenders( + loggerConfig: LoggerConfigType, + loggerConfigByContext: Map +) { + let currentContext = loggerConfig.context; + let appenders = loggerConfig.appenders; + + while (appenders.length === 0) { + const parentContext = LoggingConfig.getParentLoggerContext(currentContext); + + const parentLogger = loggerConfigByContext.get(parentContext); + if (parentLogger) { + appenders = parentLogger.appenders; + } + + currentContext = parentContext; + } + + return appenders; +} diff --git a/src/core/server/logging/logging_service.ts b/src/core/server/logging/logging_service.ts new file mode 100644 index 00000000000000..b7c6c9d7dcf087 --- /dev/null +++ b/src/core/server/logging/logging_service.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable, Subscription } from '../../lib/kbn_observable'; + +import { MutableLoggerFactory } from './logger_factory'; +import { LoggingConfig } from './logging_config'; + +/** + * Service that is responsible for maintaining the log config subscription and + * pushing updates the the logger factory. + */ +export class LoggingService { + private subscription?: Subscription; + + constructor(private readonly loggingFactory: MutableLoggerFactory) {} + + /** + * Takes `LoggingConfig` observable and pushes all config updates to the + * internal logger factory. + * @param config$ Observable that tracks all updates in the logging config. + */ + public upgrade(config$: Observable) { + this.subscription = config$.subscribe({ + next: config => this.loggingFactory.updateConfig(config), + }); + } + + /** + * Asynchronous method that causes service to unsubscribe from logging config updates + * and close internal logger factory. + */ + public async stop() { + if (this.subscription !== undefined) { + this.subscription.unsubscribe(); + } + await this.loggingFactory.close(); + } +} diff --git a/src/core/server/root/__tests__/__snapshots__/index.test.ts.snap b/src/core/server/root/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000000000..e6ada3eaf3c30c --- /dev/null +++ b/src/core/server/root/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`when configuring logger fails calls shutdown 1`] = `[Error: foo bar baz]`; + +exports[`when configuring logger fails calls shutdown 2`] = ` +Array [ + Array [ + "Configuring logger failed:", + "foo bar baz", + ], +] +`; diff --git a/src/core/server/root/__tests__/index.test.ts b/src/core/server/root/__tests__/index.test.ts new file mode 100644 index 00000000000000..fc9e3c1de37ca9 --- /dev/null +++ b/src/core/server/root/__tests__/index.test.ts @@ -0,0 +1,155 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getEnvOptions } from '../../config/__tests__/__mocks__/env'; + +const loggerConfig = {}; + +const configService = { + atPath: jest.fn(() => loggerConfig), +}; + +const mockConfigService = jest.fn(() => configService); + +const server = { + start: jest.fn(), + stop: jest.fn(), +}; +const mockServer = jest.fn(() => server); + +const loggingService = { + stop: jest.fn(), + upgrade: jest.fn(), +}; + +const logger = { + get: jest.fn(() => ({ + error: jest.fn(), + info: jest.fn(), + })), +}; + +const mockMutableLoggerFactory = jest.fn(() => logger); + +const mockLoggingService = jest.fn(() => loggingService); + +import { BehaviorSubject } from '../../../lib/kbn_observable'; + +jest.mock('../../config', () => ({ ConfigService: mockConfigService })); +jest.mock('../../', () => ({ Server: mockServer })); +jest.mock('../../logging/logging_service', () => ({ + LoggingService: mockLoggingService, +})); +jest.mock('../../logging/logger_factory', () => ({ + MutableLoggerFactory: mockMutableLoggerFactory, +})); + +import { Root } from '../'; +import { Env } from '../../config/env'; +import { RawConfig } from '../../config/raw_config'; + +const env = new Env('.', getEnvOptions()); +const config$ = new BehaviorSubject({} as RawConfig); + +const mockProcessExit = jest.spyOn(global.process, 'exit').mockImplementation(() => { + // noop +}); +afterEach(() => { + mockProcessExit.mockReset(); +}); + +test('starts services on "start"', async () => { + const root = new Root(config$, env); + + expect(loggingService.upgrade).toHaveBeenCalledTimes(0); + expect(server.start).toHaveBeenCalledTimes(0); + + await root.start(); + + expect(loggingService.upgrade).toHaveBeenCalledTimes(1); + expect(loggingService.upgrade).toHaveBeenLastCalledWith(loggerConfig); + expect(server.start).toHaveBeenCalledTimes(1); +}); + +test('stops services on "shutdown"', async () => { + const root = new Root(config$, env); + + await root.start(); + + expect(loggingService.stop).toHaveBeenCalledTimes(0); + expect(server.stop).toHaveBeenCalledTimes(0); + + await root.shutdown(); + + expect(loggingService.stop).toHaveBeenCalledTimes(1); + expect(server.stop).toHaveBeenCalledTimes(1); +}); + +test('calls onShutdown param on "shutdown"', async () => { + const onShutdown = jest.fn(); + + const root = new Root(config$, env, onShutdown); + + await root.start(); + + expect(onShutdown).toHaveBeenCalledTimes(0); + + const err = new Error('shutdown'); + + await root.shutdown(err); + + expect(onShutdown).toHaveBeenCalledTimes(1); + expect(onShutdown).toHaveBeenLastCalledWith(err); +}); + +describe('when configuring logger fails', () => { + const logged = jest.spyOn(console, 'error'); + + beforeEach(() => { + logged.mockImplementation(() => { + // noop + }); + }); + + afterEach(() => { + logged.mockRestore(); + }); + + test('calls shutdown', async () => { + const onShutdown = jest.fn(); + + const root = new Root(config$, env, onShutdown); + const err = new Error('foo bar baz'); + + configService.atPath.mockImplementationOnce(() => { + throw err; + }); + + mockServer.mockClear(); + + await expect(root.start()).rejects.toMatchSnapshot(); + + expect(mockServer).not.toHaveBeenCalled(); + + expect(onShutdown).toHaveBeenCalledTimes(1); + expect(onShutdown).toHaveBeenLastCalledWith(err); + + expect(logged.mock.calls).toMatchSnapshot(); + }); +}); diff --git a/src/core/server/root/base_path_proxy_root.ts b/src/core/server/root/base_path_proxy_root.ts new file mode 100644 index 00000000000000..0c4ebe40349be4 --- /dev/null +++ b/src/core/server/root/base_path_proxy_root.ts @@ -0,0 +1,74 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { first, k$, toPromise } from '../../lib/kbn_observable'; + +import { Root } from '.'; +import { DevConfig } from '../dev'; +import { HttpConfig } from '../http'; +import { BasePathProxyServer, BasePathProxyServerOptions } from '../http/base_path_proxy_server'; + +/** + * Top-level entry point to start BasePathProxy server. + */ +export class BasePathProxyRoot extends Root { + private basePathProxy?: BasePathProxyServer; + + public async configure({ + blockUntil, + shouldRedirectFromOldBasePath, + }: Pick) { + const [devConfig, httpConfig] = await Promise.all([ + k$(this.configService.atPath('dev', DevConfig))(first(), toPromise()), + k$(this.configService.atPath('server', HttpConfig))(first(), toPromise()), + ]); + + this.basePathProxy = new BasePathProxyServer(this.logger.get('server'), { + blockUntil, + devConfig, + httpConfig, + shouldRedirectFromOldBasePath, + }); + } + + public getBasePath() { + return this.getBasePathProxy().basePath; + } + + public getTargetPort() { + return this.getBasePathProxy().targetPort; + } + + protected async startServer() { + return this.getBasePathProxy().start(); + } + + protected async stopServer() { + await this.getBasePathProxy().stop(); + this.basePathProxy = undefined; + } + + private getBasePathProxy() { + if (this.basePathProxy === undefined) { + throw new Error('BasePathProxyRoot is not configured!'); + } + + return this.basePathProxy; + } +} diff --git a/src/core/server/root/index.ts b/src/core/server/root/index.ts new file mode 100644 index 00000000000000..36c3a01fb68ded --- /dev/null +++ b/src/core/server/root/index.ts @@ -0,0 +1,102 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from '../../lib/kbn_observable'; + +import { Server } from '..'; +import { ConfigService, Env, RawConfig } from '../config'; + +import { Logger } from '../logging'; +import { LoggerFactory, MutableLoggerFactory } from '../logging/logger_factory'; +import { LoggingConfig } from '../logging/logging_config'; +import { LoggingService } from '../logging/logging_service'; + +export type OnShutdown = (reason?: Error) => void; + +/** + * Top-level entry point to kick off the app and start the Kibana server. + */ +export class Root { + public configService: ConfigService; + public readonly log: Logger; + public readonly logger: LoggerFactory; + private server?: Server; + private readonly loggingService: LoggingService; + + constructor( + rawConfig$: Observable, + private readonly env: Env, + private readonly onShutdown: OnShutdown = () => { + // noop + } + ) { + const loggerFactory = new MutableLoggerFactory(env); + this.loggingService = new LoggingService(loggerFactory); + this.logger = loggerFactory; + + this.log = this.logger.get('root'); + this.configService = new ConfigService(rawConfig$, env, this.logger); + } + + public async start() { + try { + const loggingConfig$ = this.configService.atPath('logging', LoggingConfig); + this.loggingService.upgrade(loggingConfig$); + } catch (e) { + // This specifically console.logs because we were not able to configure + // the logger. + // tslint:disable no-console + console.error('Configuring logger failed:', e.message); + + await this.shutdown(e); + throw e; + } + + try { + await this.startServer(); + } catch (e) { + this.log.error(e); + + await this.shutdown(e); + throw e; + } + } + + public async shutdown(reason?: Error) { + await this.stopServer(); + + await this.loggingService.stop(); + + this.onShutdown(reason); + } + + protected async startServer() { + this.server = new Server(this.configService, this.logger, this.env); + return this.server.start(); + } + + protected async stopServer() { + if (this.server === undefined) { + return; + } + + await this.server.stop(); + this.server = undefined; + } +} diff --git a/src/core/test_helpers/strip_ansi_snapshot_serializer.ts b/src/core/test_helpers/strip_ansi_snapshot_serializer.ts new file mode 100644 index 00000000000000..bf8bd129c0bbf6 --- /dev/null +++ b/src/core/test_helpers/strip_ansi_snapshot_serializer.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import hasAnsi from 'has-ansi'; +import stripAnsi from 'strip-ansi'; + +export const stripAnsiSnapshotSerializer = { + print(value: string, serialize: (val: string) => string) { + return serialize(stripAnsi(value)); + }, + + test(value: any) { + return typeof value === 'string' && hasAnsi(value); + }, +}; diff --git a/src/core/types/core_service.ts b/src/core/types/core_service.ts new file mode 100644 index 00000000000000..b6031e0deb7bae --- /dev/null +++ b/src/core/types/core_service.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface CoreService { + start(): Promise; + stop(): Promise; +} diff --git a/src/core/utils/__tests__/__snapshots__/get.test.ts.snap b/src/core/utils/__tests__/__snapshots__/get.test.ts.snap new file mode 100644 index 00000000000000..f78726869b2d06 --- /dev/null +++ b/src/core/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/src/core/utils/__tests__/get.test.ts b/src/core/utils/__tests__/get.test.ts new file mode 100644 index 00000000000000..a93ad6f6d708eb --- /dev/null +++ b/src/core/utils/__tests__/get.test.ts @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get } from '../get'; + +const obj = { + bar: { + quux: 123, + }, + 'dotted.value': 'dots', + foo: 'value', +}; + +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/src/core/utils/__tests__/url.test.ts b/src/core/utils/__tests__/url.test.ts new file mode 100644 index 00000000000000..6ff3a75d6e725f --- /dev/null +++ b/src/core/utils/__tests__/url.test.ts @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { modifyUrl } from '../url'; + +describe('modifyUrl()', () => { + test('throws an error with invalid input', () => { + expect(() => modifyUrl(1 as any, () => ({}))).toThrowError(); + expect(() => modifyUrl(undefined as any, () => ({}))).toThrowError(); + expect(() => modifyUrl('http://localhost', undefined as any)).toThrowError(); + }); + + test('supports returning a new url spec', () => { + expect(modifyUrl('http://localhost', () => ({}))).toEqual(''); + }); + + test('supports modifying the passed object', () => { + expect( + modifyUrl('http://localhost', parsed => { + parsed.port = '9999'; + parsed.auth = 'foo:bar'; + return parsed; + }) + ).toEqual('http://foo:bar@localhost:9999/'); + }); + + test('supports changing pathname', () => { + expect( + modifyUrl('http://localhost/some/path', parsed => { + parsed.pathname += '/subpath'; + return parsed; + }) + ).toEqual('http://localhost/some/path/subpath'); + }); + + test('supports changing port', () => { + expect( + modifyUrl('http://localhost:5601', parsed => { + parsed.port = (Number.parseInt(parsed.port!) + 1).toString(); + return parsed; + }) + ).toEqual('http://localhost:5602/'); + }); + + test('supports changing protocol', () => { + expect( + modifyUrl('http://localhost', parsed => { + parsed.protocol = 'mail'; + parsed.slashes = false; + parsed.pathname = null; + return parsed; + }) + ).toEqual('mail:localhost'); + }); +}); diff --git a/src/core/utils/assert_never.ts b/src/core/utils/assert_never.ts new file mode 100644 index 00000000000000..8e47f07a02a87a --- /dev/null +++ b/src/core/utils/assert_never.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// 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/src/core/utils/get.ts b/src/core/utils/get.ts new file mode 100644 index 00000000000000..b8b54fe8ca9645 --- /dev/null +++ b/src/core/utils/get.ts @@ -0,0 +1,69 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * 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< + CFG extends { [k: string]: any }, + 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< + CFG extends { [k: string]: any }, + 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< + CFG extends { [k: string]: any }, + 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( + obj: CFG, + path: [A, B] +): CFG[A][B]; +export function get( + obj: CFG, + path: [A] | A +): CFG[A]; +export function get(obj: CFG, path: string[] | 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 (const key of path) { + obj = obj[key]; + } + + return obj; +} diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts new file mode 100644 index 00000000000000..484fc5b649f541 --- /dev/null +++ b/src/core/utils/index.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './get'; +export * from './pick'; +export * from './assert_never'; +export * from './url'; diff --git a/src/core/utils/pick.ts b/src/core/utils/pick.ts new file mode 100644 index 00000000000000..513517394362aa --- /dev/null +++ b/src/core/utils/pick.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function pick( + obj: T, + keys: K[] +): Pick { + const newObj = keys.reduce( + (acc, val) => { + acc[val] = obj[val]; + return acc; + }, + {} as { [k: string]: any } + ); + + return newObj as Pick; +} diff --git a/src/core/utils/url.ts b/src/core/utils/url.ts new file mode 100644 index 00000000000000..1f566b4897d102 --- /dev/null +++ b/src/core/utils/url.ts @@ -0,0 +1,97 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ParsedUrlQuery } from 'querystring'; +import { format as formatUrl, parse as parseUrl, UrlObject } from 'url'; + +export interface URLMeaningfulParts { + auth: string | null; + hash: string | null; + hostname: string | null; + pathname: string | null; + protocol: string | null; + slashes: boolean | null; + port: string | null; + query: ParsedUrlQuery | {}; +} + +/** + * Takes a URL and a function that takes the meaningful parts + * of the URL as a key-value object, modifies some or all of + * the parts, and returns the modified parts formatted again + * as a url. + * + * Url Parts sent: + * - protocol + * - slashes (does the url have the //) + * - auth + * - hostname (just the name of the host, no port or auth information) + * - port + * - pathname (the path after the hostname, no query or hash, starts + * with a slash if there was a path) + * - query (always an object, even when no query on original url) + * - hash + * + * Why? + * - The default url library in node produces several conflicting + * properties on the "parsed" output. Modifying any of these might + * lead to the modifications being ignored (depending on which + * property was modified) + * - It's not always clear whether to use path/pathname, host/hostname, + * so this tries to add helpful constraints + * + * @param url The string url to parse. + * @param urlModifier A function that will modify the parsed url, or return a new one. + * @returns The modified and reformatted url + */ +export function modifyUrl( + url: string, + urlModifier: (urlParts: URLMeaningfulParts) => Partial | undefined +) { + const parsed = parseUrl(url, true) as URLMeaningfulParts; + + // Copy over the most specific version of each property. By default, the parsed url includes several + // conflicting properties (like path and pathname + search, or search and query) and keeping track + // of which property is actually used when they are formatted is harder than necessary. + const meaningfulParts: URLMeaningfulParts = { + auth: parsed.auth, + hash: parsed.hash, + hostname: parsed.hostname, + pathname: parsed.pathname, + port: parsed.port, + protocol: parsed.protocol, + query: parsed.query || {}, + slashes: parsed.slashes, + }; + + // The urlModifier modifies the meaningfulParts object, or returns a new one. + const modifiedParts = urlModifier(meaningfulParts) || meaningfulParts; + + // Format the modified/replaced meaningfulParts back into a url. + return formatUrl({ + auth: modifiedParts.auth, + hash: modifiedParts.hash, + hostname: modifiedParts.hostname, + pathname: modifiedParts.pathname, + port: modifiedParts.port, + protocol: modifiedParts.protocol, + query: modifiedParts.query, + slashes: modifiedParts.slashes, + } as UrlObject); +} diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index b6bd17b8c565fc..dc9d0264790764 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -30,6 +30,7 @@ export default { '/src/utils', '/src/setup_node_env', '/packages', + '/src/core', ], collectCoverageFrom: [ 'packages/kbn-ui-framework/src/components/**/*.js', diff --git a/src/server/config/schema.js b/src/server/config/schema.js index ae3fe78a4e8462..fcbb649ee5d1fa 100644 --- a/src/server/config/schema.js +++ b/src/server/config/schema.js @@ -275,4 +275,8 @@ export default async () => Joi.object({ defaultLocale: Joi.string().default('en'), }).default(), + // This is a configuration node that is specifically handled by the config system + // in the new platform, and that the current platform doesn't need to handle at all. + __newPlatform: Joi.any(), + }).default(); diff --git a/src/server/http/index.js b/src/server/http/index.js index 09f2234406f3e7..690451bb2cbcaa 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -26,20 +26,38 @@ import { setupVersionCheck } from './version_check'; import { handleShortUrlError } from './short_url_error'; import { shortUrlAssertValid } from './short_url_assert_valid'; import { shortUrlLookupProvider } from './short_url_lookup'; -import { setupConnection } from './setup_connection'; -import { setupRedirectServer } from './setup_redirect_server'; import { registerHapiPlugins } from './register_hapi_plugins'; -import { setupBasePathRewrite } from './setup_base_path_rewrite'; import { setupXsrf } from './xsrf'; export default async function (kbnServer, server, config) { - server = kbnServer.server = new Hapi.Server(); + kbnServer.server = new Hapi.Server(); + server = kbnServer.server; const shortUrlLookup = shortUrlLookupProvider(server); - setupConnection(server, config); - setupBasePathRewrite(server, config); - await setupRedirectServer(config); + // Note that all connection options configured here should be exactly the same + // as in `getServerOptions()` in the new platform (see `src/core/server/http/http_tools`). + // Any change SHOULD BE applied in both places. + server.connection({ + host: config.get('server.host'), + port: config.get('server.port'), + listener: kbnServer.newPlatform.proxyListener, + state: { + strictHeader: false, + }, + routes: { + cors: config.get('server.cors'), + payload: { + maxBytes: config.get('server.maxPayloadBytes'), + }, + validate: { + options: { + abortEarly: false, + }, + }, + }, + }); + registerHapiPlugins(server); // provide a simple way to expose static directories diff --git a/src/server/http/setup_base_path_rewrite.test.js b/src/server/http/setup_base_path_rewrite.test.js deleted file mode 100644 index a7b0c9224a586f..00000000000000 --- a/src/server/http/setup_base_path_rewrite.test.js +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Server } from 'hapi'; -import sinon from 'sinon'; - -import { setupBasePathRewrite } from './setup_base_path_rewrite'; - -describe('server / setup_base_path_rewrite', () => { - function createServer({ basePath, rewriteBasePath }) { - const config = { - get: sinon.stub() - }; - - config.get.withArgs('server.basePath') - .returns(basePath); - config.get.withArgs('server.rewriteBasePath') - .returns(rewriteBasePath); - - const server = new Server(); - server.connection({ port: 0 }); - setupBasePathRewrite(server, config); - - server.route({ - method: 'GET', - path: '/', - handler(req, reply) { - reply('resp:/'); - } - }); - - server.route({ - method: 'GET', - path: '/foo', - handler(req, reply) { - reply('resp:/foo'); - } - }); - - return server; - } - - describe('no base path', () => { - let server; - beforeAll(() => server = createServer({ basePath: '', rewriteBasePath: false })); - afterAll(() => server = undefined); - - it('/bar => 404', async () => { - const resp = await server.inject({ - url: '/bar' - }); - - expect(resp.statusCode).toBe(404); - }); - - it('/bar/ => 404', async () => { - const resp = await server.inject({ - url: '/bar/' - }); - - expect(resp.statusCode).toBe(404); - }); - - it('/bar/foo => 404', async () => { - const resp = await server.inject({ - url: '/bar/foo' - }); - - expect(resp.statusCode).toBe(404); - }); - - it('/ => /', async () => { - const resp = await server.inject({ - url: '/' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('resp:/'); - }); - - it('/foo => /foo', async () => { - const resp = await server.inject({ - url: '/foo' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('resp:/foo'); - }); - }); - - describe('base path /bar, rewrite = false', () => { - let server; - beforeAll(() => server = createServer({ basePath: '/bar', rewriteBasePath: false })); - afterAll(() => server = undefined); - - it('/bar => 404', async () => { - const resp = await server.inject({ - url: '/bar' - }); - - expect(resp.statusCode).toBe(404); - }); - - it('/bar/ => 404', async () => { - const resp = await server.inject({ - url: '/bar/' - }); - - expect(resp.statusCode).toBe(404); - }); - - it('/bar/foo => 404', async () => { - const resp = await server.inject({ - url: '/bar/foo' - }); - - expect(resp.statusCode).toBe(404); - }); - - it('/ => /', async () => { - const resp = await server.inject({ - url: '/' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('resp:/'); - }); - - it('/foo => /foo', async () => { - const resp = await server.inject({ - url: '/foo' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('resp:/foo'); - }); - }); - - describe('base path /bar, rewrite = true', () => { - let server; - beforeAll(() => server = createServer({ basePath: '/bar', rewriteBasePath: true })); - afterAll(() => server = undefined); - - it('/bar => /', async () => { - const resp = await server.inject({ - url: '/bar' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('resp:/'); - }); - - it('/bar/ => 404', async () => { - const resp = await server.inject({ - url: '/bar/' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('resp:/'); - }); - - it('/bar/foo => 404', async () => { - const resp = await server.inject({ - url: '/bar/foo' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('resp:/foo'); - }); - - it('/ => 404', async () => { - const resp = await server.inject({ - url: '/' - }); - - expect(resp.statusCode).toBe(404); - }); - - it('/foo => 404', async () => { - const resp = await server.inject({ - url: '/foo' - }); - - expect(resp.statusCode).toBe(404); - }); - }); -}); diff --git a/src/server/http/setup_connection.js b/src/server/http/setup_connection.js index 4b02ee10e3221e..e69de29bb2d1d6 100644 --- a/src/server/http/setup_connection.js +++ b/src/server/http/setup_connection.js @@ -1,85 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { readFileSync } from 'fs'; -import secureOptions from './secure_options'; - -export function setupConnection(server, config) { - const host = config.get('server.host'); - const port = config.get('server.port'); - - const connectionOptions = { - host, - port, - state: { - strictHeader: false - }, - routes: { - cors: config.get('server.cors'), - payload: { - maxBytes: config.get('server.maxPayloadBytes') - }, - validate: { - options: { - abortEarly: false - } - } - } - }; - - const useSsl = config.get('server.ssl.enabled'); - - // not using https? well that's easy! - if (!useSsl) { - const connection = server.connection(connectionOptions); - - // revert to previous 5m keepalive timeout in Node < 8 - connection.listener.keepAliveTimeout = 120e3; - - return; - } - - const connection = server.connection({ - ...connectionOptions, - tls: { - key: readFileSync(config.get('server.ssl.key')), - cert: readFileSync(config.get('server.ssl.certificate')), - ca: config.get('server.ssl.certificateAuthorities').map(ca => readFileSync(ca, 'utf8')), - passphrase: config.get('server.ssl.keyPassphrase'), - - ciphers: config.get('server.ssl.cipherSuites').join(':'), - // We use the server's cipher order rather than the client's to prevent the BEAST attack - honorCipherOrder: true, - secureOptions: secureOptions(config.get('server.ssl.supportedProtocols')) - } - }); - - // revert to previous 5m keepalive timeout in Node < 8 - connection.listener.keepAliveTimeout = 120e3; - - const badRequestResponse = new Buffer('HTTP/1.1 400 Bad Request\r\n\r\n', 'ascii'); - connection.listener.on('clientError', (err, socket) => { - if (socket.writable) { - socket.end(badRequestResponse); - } - else { - socket.destroy(err); - } - }); -} diff --git a/src/server/http/setup_redirect_server.js b/src/server/http/setup_redirect_server.js deleted file mode 100644 index eb5dde66e9190c..00000000000000 --- a/src/server/http/setup_redirect_server.js +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { format as formatUrl } from 'url'; -import { fromNode } from 'bluebird'; -import Hapi from 'hapi'; - -// If a redirect port is specified, we start an http server at this port and -// redirect all requests to the ssl port. -export async function setupRedirectServer(config) { - const isSslEnabled = config.get('server.ssl.enabled'); - const portToRedirectFrom = config.get('server.ssl.redirectHttpFromPort'); - - // Both ssl and port to redirect from must be specified - if (!isSslEnabled || portToRedirectFrom === undefined) { - return; - } - - const host = config.get('server.host'); - const sslPort = config.get('server.port'); - - if (portToRedirectFrom === sslPort) { - throw new Error( - 'Kibana does not accept http traffic to `server.port` when ssl is ' + - 'enabled (only https is allowed), so `server.ssl.redirectHttpFromPort` ' + - `cannot be configured to the same value. Both are [${sslPort}].` - ); - } - - const redirectServer = new Hapi.Server(); - - redirectServer.connection({ - host, - port: portToRedirectFrom - }); - - redirectServer.ext('onRequest', (req, reply) => { - reply.redirect(formatUrl({ - protocol: 'https', - hostname: host, - port: sslPort, - pathname: req.url.pathname, - search: req.url.search, - })); - }); - - try { - await fromNode(cb => redirectServer.start(cb)); - } catch (err) { - if (err.code === 'EADDRINUSE') { - throw new Error( - 'The redirect server failed to start up because port ' + - `${portToRedirectFrom} is already in use. Ensure the port specified ` + - 'in `server.ssl.redirectHttpFromPort` is available.' - ); - } else { - throw err; - } - } -} diff --git a/src/server/kbn_server.js b/src/server/kbn_server.js index a993af9729cfc2..7e9895d4a3afd2 100644 --- a/src/server/kbn_server.js +++ b/src/server/kbn_server.js @@ -21,7 +21,6 @@ import { constant, once, compact, flatten } from 'lodash'; import { fromNode } from 'bluebird'; import { isWorker } from 'cluster'; import { fromRoot, pkg } from '../utils'; -import { Config } from './config'; import loggingConfiguration from './logging/configuration'; import configSetupMixin from './config/setup'; import httpMixin from './http'; @@ -41,6 +40,7 @@ import { kibanaIndexMappingsMixin } from './mappings'; import { serverExtensionsMixin } from './server_extensions'; import { uiMixin } from '../ui'; import { sassMixin } from './sass'; +import { injectIntoKbnServer as newPlatformMixin } from '../core'; const rootDir = fromRoot('.'); @@ -57,8 +57,12 @@ export default class KbnServer { // sets this.config, reads this.settings configSetupMixin, + + newPlatformMixin, + // sets this.server httpMixin, + // adds methods for extending this.server serverExtensionsMixin, loggingMixin, @@ -169,8 +173,7 @@ export default class KbnServer { return await this.server.inject(opts); } - async applyLoggingConfiguration(settings) { - const config = await Config.withDefaultSchema(settings); + async applyLoggingConfiguration(config) { const loggingOptions = loggingConfiguration(config); const subset = { ops: config.get('ops'), diff --git a/yarn.lock b/yarn.lock index 03747a9586968a..9f952fdf035f7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -271,10 +271,26 @@ version "3.5.20" resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.20.tgz#f6363172add6f4eabb8cada53ca9af2781e8d6a1" +"@types/boom@*": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@types/boom/-/boom-7.2.0.tgz#19c36cbb5811a7493f0f2e37f31d42b28df1abc1" + +"@types/catbox@*": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@types/catbox/-/catbox-10.0.0.tgz#1e01e5ad83e224f110cc59f6f57c56558f7eeb61" + +"@types/chance@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/chance/-/chance-1.0.1.tgz#c10703020369602c40dd9428cc6e1437027116df" + "@types/classnames@^2.2.3": version "2.2.3" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5" +"@types/cookiejar@*": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.0.tgz#4b7daf2c51696cfc70b942c11690528229d1a1ce" + "@types/delay@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/delay/-/delay-2.0.1.tgz#61bcf318a74b61e79d1658fbf054f984c90ef901" @@ -324,6 +340,29 @@ dependencies: "@types/node" "*" +"@types/hapi-latest@npm:@types/hapi@17.0.12": + version "17.0.12" + resolved "https://registry.yarnpkg.com/@types/hapi/-/hapi-17.0.12.tgz#5751f4d8db4decb4eae6671a4efbeae671278ceb" + dependencies: + "@types/boom" "*" + "@types/catbox" "*" + "@types/iron" "*" + "@types/joi" "*" + "@types/mimos" "*" + "@types/node" "*" + "@types/podium" "*" + "@types/shot" "*" + +"@types/has-ansi@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/has-ansi/-/has-ansi-3.0.0.tgz#636403dc4e0b2649421c4158e5c404416f3f0330" + +"@types/iron@*": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/iron/-/iron-5.0.1.tgz#5420bbda8623c48ee51b9a78ebad05d7305b4b24" + dependencies: + "@types/node" "*" + "@types/is-stream@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@types/is-stream/-/is-stream-1.1.0.tgz#b84d7bb207a210f2af9bed431dc0fbe9c4143be1" @@ -334,10 +373,22 @@ version "22.2.3" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-22.2.3.tgz#0157c0316dc3722c43a7b71de3fdf3acbccef10d" +"@types/joi@*": + version "13.3.0" + resolved "https://registry.yarnpkg.com/@types/joi/-/joi-13.3.0.tgz#bdfa2e49d8d258ba79f23304228d0c4d5cfc848c" + +"@types/joi@^10.4.4": + version "10.6.2" + resolved "https://registry.yarnpkg.com/@types/joi/-/joi-10.6.2.tgz#0e7d632fe918c337784e87b16c7cc0098876179a" + "@types/jquery@3.3.1": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.3.1.tgz#55758d44d422756d6329cbf54e6d41931d7ba28f" +"@types/js-yaml@^3.11.1": + version "3.11.2" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.11.2.tgz#699ad86054cc20043c30d66a6fcde30bbf5d3d5e" + "@types/json-schema@*": version "6.0.1" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-6.0.1.tgz#a761975746f1c1b2579c62e3a4b5e88f986f7e2e" @@ -360,6 +411,16 @@ version "1.5.3" resolved "https://registry.yarnpkg.com/@types/loglevel/-/loglevel-1.5.3.tgz#adfce55383edc5998a2170ad581b3e23d6adb5b8" +"@types/mime-db@*": + version "1.27.0" + resolved "https://registry.yarnpkg.com/@types/mime-db/-/mime-db-1.27.0.tgz#9bc014a1fd1fdf47649c1a54c6dd7966b8284792" + +"@types/mimos@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/mimos/-/mimos-3.0.1.tgz#59d96abe1c9e487e7463fe41e8d86d76b57a441a" + dependencies: + "@types/mime-db" "*" + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -372,6 +433,10 @@ version "9.4.7" resolved "https://registry.yarnpkg.com/@types/node/-/node-9.4.7.tgz#57d81cd98719df2c9de118f2d5f3b1120dcd7275" +"@types/node@^8.10.20": + version "8.10.21" + resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.21.tgz#12b3f2359b27aa05a45d886c8ba1eb8d1a77e285" + "@types/node@^9.4.7": version "9.6.18" resolved "https://registry.yarnpkg.com/@types/node/-/node-9.6.18.tgz#092e13ef64c47e986802c9c45a61c1454813b31d" @@ -390,6 +455,10 @@ dependencies: "@types/retry" "*" +"@types/podium@*": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/podium/-/podium-1.0.0.tgz#bfaa2151be2b1d6109cc69f7faa9dac2cba3bb20" + "@types/prop-types@^15.5.3": version "15.5.3" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.3.tgz#bef071852dca2a2dbb65fecdb7bfb30cedae2de2" @@ -419,6 +488,37 @@ version "0.10.2" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.10.2.tgz#bd1740c4ad51966609b058803ee6874577848b37" +"@types/shot@*": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@types/shot/-/shot-3.4.0.tgz#459477c5187d3ebd303660ab099e7e9e0f3b656f" + dependencies: + "@types/node" "*" + +"@types/sinon@^5.0.0": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-5.0.1.tgz#a15b36ec42f1f53166617491feabd1734cb03e21" + +"@types/strip-ansi@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/strip-ansi/-/strip-ansi-3.0.0.tgz#9b63d453a6b54aa849182207711a08be8eea48ae" + +"@types/superagent@*": + version "3.8.2" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-3.8.2.tgz#ffdda92843f8966fb4c5f482755ee641ffc53aa7" + dependencies: + "@types/cookiejar" "*" + "@types/node" "*" + +"@types/supertest@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.4.tgz#28770e13293365e240a842d7d5c5a1b3d2dee593" + dependencies: + "@types/superagent" "*" + +"@types/type-detect@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/type-detect/-/type-detect-4.0.1.tgz#3b0f5ac82ea630090cbf57c57a1bf5a63a29b9b6" + "@types/url-join@^0.8.2": version "0.8.2" resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-0.8.2.tgz#1181ecbe1d97b7034e0ea1e35e62e86cc26b422d" @@ -460,6 +560,13 @@ accept@2.x.x: boom "5.x.x" hoek "4.x.x" +accept@3.x.x: + version "3.0.2" + resolved "https://registry.yarnpkg.com/accept/-/accept-3.0.2.tgz#83e41cec7e1149f3fd474880423873db6c6cc9ac" + dependencies: + boom "7.x.x" + hoek "5.x.x" + accepts@1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca" @@ -594,6 +701,12 @@ ammo@2.x.x: boom "5.x.x" hoek "4.x.x" +ammo@3.x.x: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ammo/-/ammo-3.0.1.tgz#c79ceeac36fb4e55085ea3fe0c2f42bfa5f7c914" + dependencies: + hoek "5.x.x" + angular-aria@1.6.6: version "1.6.6" resolved "https://registry.yarnpkg.com/angular-aria/-/angular-aria-1.6.6.tgz#58dd748e09564bc8409f739bde57b35fbee5b6a5" @@ -1010,6 +1123,10 @@ b64@3.x.x: version "3.0.3" resolved "https://registry.yarnpkg.com/b64/-/b64-3.0.3.tgz#36afeee0d9345f046387ce6de8a6702afe5bb56e" +b64@4.x.x: + version "4.0.0" + resolved "https://registry.yarnpkg.com/b64/-/b64-4.0.0.tgz#c37f587f0a383c7019e821120e8c3f58f0d22772" + babel-code-frame@^6.11.0, babel-code-frame@^6.20.0, babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -1823,6 +1940,10 @@ better-assert@~1.0.0: dependencies: callsite "1.0.0" +big-time@2.x.x: + version "2.0.1" + resolved "https://registry.yarnpkg.com/big-time/-/big-time-2.0.1.tgz#68c7df8dc30f97e953f25a67a76ac9713c16c9de" + big.js@^3.1.3: version "3.2.0" resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" @@ -1957,6 +2078,19 @@ boom@5.2.0, boom@5.x.x: dependencies: hoek "4.x.x" +boom@7.x.x: + version "7.2.0" + resolved "https://registry.yarnpkg.com/boom/-/boom-7.2.0.tgz#2bff24a55565767fde869ec808317eb10c48e966" + dependencies: + hoek "5.x.x" + +bounce@1.x.x: + version "1.2.0" + resolved "https://registry.yarnpkg.com/bounce/-/bounce-1.2.0.tgz#e3bac68c73fd256e38096551efc09f504873c8c8" + dependencies: + boom "7.x.x" + hoek "5.x.x" + boxen@^1.2.1, boxen@^1.2.2: version "1.3.0" resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" @@ -2279,6 +2413,13 @@ call@3.x.x: boom "4.x.x" hoek "4.x.x" +call@5.x.x: + version "5.0.1" + resolved "https://registry.yarnpkg.com/call/-/call-5.0.1.tgz#ac1b5c106d9edc2a17af2a4a4f74dd4f0c06e910" + dependencies: + boom "7.x.x" + hoek "5.x.x" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" @@ -2358,6 +2499,23 @@ catbox-memory@2.x.x: dependencies: hoek "4.x.x" +catbox-memory@3.x.x: + version "3.1.2" + resolved "https://registry.yarnpkg.com/catbox-memory/-/catbox-memory-3.1.2.tgz#4aeec1bc994419c0f7e60087f172aaedd9b4911c" + dependencies: + big-time "2.x.x" + boom "7.x.x" + hoek "5.x.x" + +catbox@10.x.x: + version "10.0.2" + resolved "https://registry.yarnpkg.com/catbox/-/catbox-10.0.2.tgz#e6ac1f35102d1a9bd07915b82e508d12b50a8bfa" + dependencies: + boom "7.x.x" + bounce "1.x.x" + hoek "5.x.x" + joi "13.x.x" + catbox@7.x.x: version "7.1.5" resolved "https://registry.yarnpkg.com/catbox/-/catbox-7.1.5.tgz#c56f7e8e9555d27c0dc038a96ef73e57d186bb1f" @@ -2992,6 +3150,12 @@ content@3.x.x: dependencies: boom "5.x.x" +content@4.x.x: + version "4.0.5" + resolved "https://registry.yarnpkg.com/content/-/content-4.0.5.tgz#bc547deabc889ab69bce17faf3585c29f4c41bf2" + dependencies: + boom "7.x.x" + contra@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/contra/-/contra-1.9.1.tgz#60e498274b3d2d332896d60f82900aefa2ecac8c" @@ -3153,6 +3317,12 @@ cryptiles@3.x.x: dependencies: boom "5.x.x" +cryptiles@4.x.x: + version "4.1.2" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-4.1.2.tgz#363c9ab5c859da9d2d6fb901b64d980966181184" + dependencies: + boom "7.x.x" + crypto-browserify@^3.11.0: version "3.12.0" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" @@ -5848,6 +6018,15 @@ gulp-sourcemaps@1.7.3: through2 "2.X" vinyl "1.X" +"h2o2-latest@npm:h2o2@8.1.2": + version "8.1.2" + resolved "https://registry.yarnpkg.com/h2o2/-/h2o2-8.1.2.tgz#25e6f69f453175c9ca1e3618741c5ebe1b5000c1" + dependencies: + boom "7.x.x" + hoek "5.x.x" + joi "13.x.x" + wreck "14.x.x" + h2o2@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/h2o2/-/h2o2-5.1.1.tgz#dc09d59e8771d0ffc9f3bdba2e6b72ef6151c1e3" @@ -5885,6 +6064,28 @@ hapi-auth-cookie@6.1.1: hoek "3.x.x" joi "7.x.x" +"hapi-latest@npm:hapi@17.5.0": + version "17.5.0" + resolved "https://registry.yarnpkg.com/hapi/-/hapi-17.5.0.tgz#9fc33f10d6f563d0203853937b60dd13a59b51ce" + dependencies: + accept "3.x.x" + ammo "3.x.x" + boom "7.x.x" + bounce "1.x.x" + call "5.x.x" + catbox "10.x.x" + catbox-memory "3.x.x" + heavy "6.x.x" + hoek "5.x.x" + joi "13.x.x" + mimos "4.x.x" + podium "3.x.x" + shot "4.x.x" + statehood "6.x.x" + subtext "6.x.x" + teamwork "3.x.x" + topo "3.x.x" + hapi@14.2.0: version "14.2.0" resolved "https://registry.yarnpkg.com/hapi/-/hapi-14.2.0.tgz#e4fe2fc182598a0f81e87b41b6be0fbd31c75409" @@ -5952,6 +6153,12 @@ has-ansi@^2.0.0: dependencies: ansi-regex "^2.0.0" +has-ansi@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-3.0.0.tgz#36077ef1d15f333484aa7fa77a28606f1c655b37" + dependencies: + ansi-regex "^3.0.0" + has-binary@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/has-binary/-/has-binary-0.1.7.tgz#68e61eb16210c9545a0a5cce06a873912fe1e68c" @@ -6081,6 +6288,14 @@ heavy@4.x.x: hoek "4.x.x" joi "10.x.x" +heavy@6.x.x: + version "6.1.0" + resolved "https://registry.yarnpkg.com/heavy/-/heavy-6.1.0.tgz#1bbfa43dc61dd4b543ede3ff87db8306b7967274" + dependencies: + boom "7.x.x" + hoek "5.x.x" + joi "13.x.x" + highlight.js@^9.12.0, highlight.js@~9.12.0: version "9.12.0" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e" @@ -6506,6 +6721,14 @@ iron@4.x.x: cryptiles "3.x.x" hoek "4.x.x" +iron@5.x.x: + version "5.0.4" + resolved "https://registry.yarnpkg.com/iron/-/iron-5.0.4.tgz#003ed822f656f07c2b62762815f5de3947326867" + dependencies: + boom "7.x.x" + cryptiles "4.x.x" + hoek "5.x.x" + is-absolute-url@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" @@ -7411,6 +7634,14 @@ joi@10.x.x: items "2.x.x" topo "2.x.x" +joi@13.x.x, joi@^13.2.0: + version "13.4.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-13.4.0.tgz#afc359ee3d8bc5f9b9ba6cdc31b46d44af14cecc" + dependencies: + hoek "5.x.x" + isemail "3.x.x" + topo "3.x.x" + joi@6.10.1, joi@6.x.x: version "6.10.1" resolved "https://registry.yarnpkg.com/joi/-/joi-6.10.1.tgz#4d50c318079122000fe5f16af1ff8e1917b77e06" @@ -7448,14 +7679,6 @@ joi@9.X.X, joi@9.x.x: moment "2.x.x" topo "2.x.x" -joi@^13.2.0: - version "13.4.0" - resolved "https://registry.yarnpkg.com/joi/-/joi-13.4.0.tgz#afc359ee3d8bc5f9b9ba6cdc31b46d44af14cecc" - dependencies: - hoek "5.x.x" - isemail "3.x.x" - topo "3.x.x" - jpeg-js@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.2.0.tgz#53e448ec9d263e683266467e9442d2c5a2ef5482" @@ -8733,6 +8956,13 @@ mimos@3.x.x: hoek "4.x.x" mime-db "1.x.x" +mimos@4.x.x: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mimos/-/mimos-4.0.0.tgz#76e3d27128431cb6482fd15b20475719ad626a5a" + dependencies: + hoek "5.x.x" + mime-db "1.x.x" + min-document@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" @@ -8986,6 +9216,13 @@ nigel@2.x.x: hoek "4.x.x" vise "2.x.x" +nigel@3.x.x: + version "3.0.1" + resolved "https://registry.yarnpkg.com/nigel/-/nigel-3.0.1.tgz#48a08859d65177312f1c25af7252c1e07bb07c2a" + dependencies: + hoek "5.x.x" + vise "3.x.x" + nise@^1.2.0: version "1.3.3" resolved "https://registry.yarnpkg.com/nise/-/nise-1.3.3.tgz#c17a850066a8a1dfeb37f921da02441afc4a82ba" @@ -9801,6 +10038,16 @@ pez@2.x.x: hoek "4.x.x" nigel "2.x.x" +pez@4.x.x: + version "4.0.2" + resolved "https://registry.yarnpkg.com/pez/-/pez-4.0.2.tgz#0a7c81b64968e90b0e9562b398f390939e9c4b53" + dependencies: + b64 "4.x.x" + boom "7.x.x" + content "4.x.x" + hoek "5.x.x" + nigel "3.x.x" + pify@^2.0.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -9903,6 +10150,13 @@ pngjs@^3.0.0: version "3.3.2" resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.3.2.tgz#097c3c2a75feb223eadddea6bc9f0050cf830bc3" +podium@3.x.x: + version "3.1.2" + resolved "https://registry.yarnpkg.com/podium/-/podium-3.1.2.tgz#b701429739cf6bdde6b3015ae6b48d400817ce9e" + dependencies: + hoek "5.x.x" + joi "13.x.x" + polished@^1.9.2: version "1.9.2" resolved "https://registry.yarnpkg.com/polished/-/polished-1.9.2.tgz#d705cac66f3a3ed1bd38aad863e2c1e269baf6b6" @@ -11739,6 +11993,13 @@ shot@3.x.x: hoek "4.x.x" joi "10.x.x" +shot@4.x.x: + version "4.0.5" + resolved "https://registry.yarnpkg.com/shot/-/shot-4.0.5.tgz#c7e7455d11d60f6b6cd3c43e15a3b431c17e5566" + dependencies: + hoek "5.x.x" + joi "13.x.x" + sigmund@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" @@ -12114,6 +12375,17 @@ statehood@4.x.x: items "2.x.x" joi "9.x.x" +statehood@6.x.x: + version "6.0.6" + resolved "https://registry.yarnpkg.com/statehood/-/statehood-6.0.6.tgz#0dbd7c50774d3f61a24e42b0673093bbc81fa5f0" + dependencies: + boom "7.x.x" + bounce "1.x.x" + cryptiles "4.x.x" + hoek "5.x.x" + iron "5.x.x" + joi "13.x.x" + static-eval@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.0.tgz#0e821f8926847def7b4b50cda5d55c04a9b13864" @@ -12357,6 +12629,16 @@ subtext@4.x.x: pez "2.x.x" wreck "12.x.x" +subtext@6.x.x: + version "6.0.7" + resolved "https://registry.yarnpkg.com/subtext/-/subtext-6.0.7.tgz#8e40a67901a734d598142665c90e398369b885f9" + dependencies: + boom "7.x.x" + content "4.x.x" + hoek "5.x.x" + pez "4.x.x" + wreck "14.x.x" + suffix@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/suffix/-/suffix-0.1.1.tgz#cc58231646a0ef1102f79478ef3a9248fd9c842f" @@ -12553,6 +12835,10 @@ tar@^2.0.0, tar@^2.2.1: fstream "^1.0.2" inherits "2" +teamwork@3.x.x: + version "3.0.1" + resolved "https://registry.yarnpkg.com/teamwork/-/teamwork-3.0.1.tgz#ff38c7161f41f8070b7813716eb6154036ece196" + term-size@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69" @@ -12968,7 +13254,7 @@ type-detect@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-1.0.0.tgz#762217cc06db258ec48908a1298e8b95121e8ea2" -type-detect@^4.0.5: +type-detect@^4.0.5, type-detect@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" @@ -13701,6 +13987,12 @@ vise@2.x.x: dependencies: hoek "4.x.x" +vise@3.x.x: + version "3.0.0" + resolved "https://registry.yarnpkg.com/vise/-/vise-3.0.0.tgz#76ad14ab31669c50fbb0817bc0e72fedcbb3bf4c" + dependencies: + hoek "5.x.x" + vision@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/vision/-/vision-4.1.0.tgz#c0c49c9287423cfcf7dbedf51ae6a67b065c6ae7" @@ -13967,6 +14259,13 @@ wreck@12.x.x: boom "5.x.x" hoek "4.x.x" +wreck@14.x.x: + version "14.0.2" + resolved "https://registry.yarnpkg.com/wreck/-/wreck-14.0.2.tgz#89c17a9061c745ed1c3aebcb66ea181dbaab454c" + dependencies: + boom "7.x.x" + hoek "5.x.x" + wreck@6.x.x: version "6.3.0" resolved "https://registry.yarnpkg.com/wreck/-/wreck-6.3.0.tgz#a1369769f07bbb62d6a378336a7871fc773c740b"

{ + return new ObjectType(props, options); +} + +function arrayOf(itemType: Type, options?: ArrayOptions): Type { + return new ArrayType(itemType, options); +} + +function mapOf( + keyType: Type, + valueType: Type, + options?: MapOfOptions +): Type> { + return new MapOfType(keyType, valueType, options); +} + +function oneOf( + types: [Type, Type, Type, Type, Type, Type, Type, Type, Type, Type], + options?: TypeOptions +): Type; +function oneOf( + types: [Type, Type, Type, Type, Type, Type, Type, Type, Type], + options?: TypeOptions +): Type; +function oneOf( + types: [Type, Type, Type, Type, Type, Type, Type, Type], + options?: TypeOptions +): Type; +function oneOf( + types: [Type, Type, Type, Type, Type, Type, Type], + options?: TypeOptions +): Type; +function oneOf( + types: [Type, Type, Type, Type, Type, Type], + options?: TypeOptions +): Type; +function oneOf( + types: [Type, Type, Type, Type, Type], + options?: TypeOptions +): Type; +function oneOf( + types: [Type, Type, Type, Type], + options?: TypeOptions +): Type; +function oneOf( + types: [Type, Type, Type], + options?: TypeOptions +): Type; +function oneOf(types: [Type, Type], options?: TypeOptions): Type; +function oneOf(types: [Type], options?: TypeOptions): Type; +function oneOf(types: RTS, options?: TypeOptions): Type { + return new UnionType(types, options); +} + +function contextRef(key: string): ContextReference { + return new ContextReference(key); +} + +function siblingRef(key: string): SiblingReference { + return new SiblingReference(key); +} + +function conditional( + leftOperand: Reference, + rightOperand: Reference | A, + equalType: Type, + notEqualType: Type, + options?: TypeOptions +) { + return new ConditionalType(leftOperand, rightOperand, equalType, notEqualType, options); +} + +export const schema = { + arrayOf, + boolean, + byteSize, + conditional, + contextRef, + duration, + literal, + mapOf, + maybe, + number, + object, + oneOf, + siblingRef, + string, +}; + +export type Schema = typeof schema; diff --git a/src/core/server/config/schema/internals/index.ts b/src/core/server/config/schema/internals/index.ts new file mode 100644 index 00000000000000..126d0e9292be8a --- /dev/null +++ b/src/core/server/config/schema/internals/index.ts @@ -0,0 +1,287 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import * as Joi from 'joi'; +import { + AnySchema, + JoiRoot, + Reference, + Rules, + State, + ValidationErrorItem, + ValidationOptions, +} from 'joi'; +import { isPlainObject } from 'lodash'; +import { isDuration } from 'moment'; +import { ByteSizeValue, ensureByteSizeValue } from '../byte_size_value'; +import { ensureDuration } from '../duration'; + +export { AnySchema, Reference, ValidationErrorItem }; + +function isMap(o: any): o is Map { + return o instanceof Map; +} + +const anyCustomRule: Rules = { + name: 'custom', + params: { + validator: Joi.func() + .maxArity(1) + .required(), + }, + validate(params, value, state, options) { + let validationResultMessage; + try { + validationResultMessage = params.validator(value); + } catch (e) { + validationResultMessage = e.message || e; + } + + if (typeof validationResultMessage === 'string') { + return this.createError( + 'any.custom', + { value, message: validationResultMessage }, + state, + options + ); + } + + return value; + }, +}; + +export const internals = Joi.extend([ + { + name: 'any', + + rules: [anyCustomRule], + }, + { + name: 'boolean', + + base: Joi.boolean(), + coerce(value: any, state: State, options: ValidationOptions) { + // If value isn't defined, let Joi handle default value if it's defined. + if (value !== undefined && typeof value !== 'boolean') { + return this.createError('boolean.base', { value }, state, options); + } + + return value; + }, + rules: [anyCustomRule], + }, + { + name: 'string', + + base: Joi.string(), + rules: [anyCustomRule], + }, + { + name: 'bytes', + + coerce(value: any, state: State, options: ValidationOptions) { + try { + if (typeof value === 'string') { + return ByteSizeValue.parse(value); + } + + if (typeof value === 'number') { + return new ByteSizeValue(value); + } + } catch (e) { + return this.createError('bytes.parse', { value, message: e.message }, state, options); + } + + return value; + }, + pre(value: any, state: State, options: ValidationOptions) { + // If value isn't defined, let Joi handle default value if it's defined. + if (value instanceof ByteSizeValue) { + return value as any; + } + + return this.createError('bytes.base', { value }, state, options); + }, + rules: [ + anyCustomRule, + { + name: 'min', + params: { + limit: Joi.alternatives([Joi.number(), Joi.string()]).required(), + }, + validate(params, value, state, options) { + const limit = ensureByteSizeValue(params.limit); + if (value.isLessThan(limit)) { + return this.createError('bytes.min', { value, limit }, state, options); + } + + return value; + }, + }, + { + name: 'max', + params: { + limit: Joi.alternatives([Joi.number(), Joi.string()]).required(), + }, + validate(params, value, state, options) { + const limit = ensureByteSizeValue(params.limit); + if (value.isGreaterThan(limit)) { + return this.createError('bytes.max', { value, limit }, state, options); + } + + return value; + }, + }, + ], + }, + { + name: 'duration', + + coerce(value: any, state: State, options: ValidationOptions) { + try { + if (typeof value === 'string' || typeof value === 'number') { + return ensureDuration(value); + } + } catch (e) { + return this.createError('duration.parse', { value, message: e.message }, state, options); + } + + return value; + }, + pre(value: any, state: State, options: ValidationOptions) { + if (!isDuration(value)) { + return this.createError('duration.base', { value }, state, options); + } + + return value; + }, + rules: [anyCustomRule], + }, + { + name: 'number', + + base: Joi.number(), + coerce(value: any, state: State, options: ValidationOptions) { + // If value isn't defined, let Joi handle default value if it's defined. + if (value === undefined) { + return 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) + const coercedValue: any = typeof value === 'string' ? Number(value) : value; + if (typeof coercedValue !== 'number' || isNaN(coercedValue)) { + return this.createError('number.base', { value }, state, options); + } + + return value; + }, + rules: [anyCustomRule], + }, + { + name: 'object', + + base: Joi.object(), + coerce(value: any, state: State, options: ValidationOptions) { + // If value isn't defined, let Joi handle default value if it's defined. + if (value !== undefined && !isPlainObject(value)) { + return this.createError('object.base', { value }, state, options); + } + + return value; + }, + rules: [anyCustomRule], + }, + { + name: 'map', + + coerce(value: any, state: State, options: ValidationOptions) { + if (isPlainObject(value)) { + return new Map(Object.entries(value)); + } + + return value; + }, + pre(value: any, state: State, options: ValidationOptions) { + if (!isMap(value)) { + return this.createError('map.base', { value }, state, options); + } + + return value as any; + }, + rules: [ + anyCustomRule, + { + name: 'entries', + params: { + key: Joi.object().schema(), + value: Joi.object().schema(), + }, + validate(params, value, state, options) { + const result = new Map(); + for (const [entryKey, entryValue] of value) { + const { value: validatedEntryKey, error: keyError } = Joi.validate( + entryKey, + params.key + ); + + if (keyError) { + return this.createError('map.key', { entryKey, reason: keyError }, state, options); + } + + const { value: validatedEntryValue, error: valueError } = Joi.validate( + entryValue, + params.value + ); + + if (valueError) { + return this.createError( + 'map.value', + { entryKey, reason: valueError }, + state, + options + ); + } + + result.set(validatedEntryKey, validatedEntryValue); + } + + return result as any; + }, + }, + ], + }, + { + name: 'array', + + base: Joi.array(), + coerce(value: any, state: State, options: ValidationOptions) { + // If value isn't defined, let Joi handle default value if it's defined. + if (value !== undefined && !Array.isArray(value)) { + return this.createError('array.base', { value }, state, options); + } + + return value; + }, + rules: [anyCustomRule], + }, +]) as JoiRoot; diff --git a/src/core/server/config/schema/internals/internals_schema.d.ts b/src/core/server/config/schema/internals/internals_schema.d.ts new file mode 100644 index 00000000000000..41fa611ac205c8 --- /dev/null +++ b/src/core/server/config/schema/internals/internals_schema.d.ts @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import * as Joi from 'joi'; +import { ByteSizeValue } from '../byte_size_value'; + +declare module 'joi' { + interface BytesSchema extends AnySchema { + min(limit: number | string | ByteSizeValue): this; + max(limit: number | string | ByteSizeValue): this; + } + + interface MapSchema extends AnySchema { + entries(key: AnySchema, value: AnySchema): this; + } + + // In more recent Joi types we can use `Root` type instead of `typeof Joi`. + export type JoiRoot = typeof Joi & { + bytes: () => BytesSchema; + duration: () => AnySchema; + map: () => MapSchema; + }; + + interface AnySchema { + custom(validator: (value: any) => string | void): this; + } + + // Joi types don't include `schema` function even though it's supported. + interface ObjectSchema { + schema(): this; + } + + // Joi types define only signature with single extension, but Joi supports + // an array form as well. It's fixed in more recent Joi types. + function extend(extension: Joi.Extension | Joi.Extension[]): any; +} diff --git a/src/core/server/config/schema/references/context_reference.ts b/src/core/server/config/schema/references/context_reference.ts new file mode 100644 index 00000000000000..5654661147ab75 --- /dev/null +++ b/src/core/server/config/schema/references/context_reference.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Reference } from './reference'; + +export class ContextReference extends Reference { + constructor(key: string) { + super(`$${key}`); + } +} diff --git a/src/core/server/config/schema/references/index.ts b/src/core/server/config/schema/references/index.ts new file mode 100644 index 00000000000000..1ab13e0fa84282 --- /dev/null +++ b/src/core/server/config/schema/references/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { Reference } from './reference'; +export { ContextReference } from './context_reference'; +export { SiblingReference } from './sibling_reference'; diff --git a/src/core/server/config/schema/references/reference.ts b/src/core/server/config/schema/references/reference.ts new file mode 100644 index 00000000000000..5dffc990f3b7bf --- /dev/null +++ b/src/core/server/config/schema/references/reference.ts @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { internals, Reference as InternalReference } from '../internals'; + +export class Reference { + public static isReference(value: V | Reference | undefined): value is Reference { + return ( + value !== undefined && + typeof (value as Reference).getSchema === 'function' && + internals.isRef((value as Reference).getSchema()) + ); + } + + private readonly internalSchema: InternalReference; + + constructor(key: string) { + this.internalSchema = internals.ref(key); + } + + public getSchema() { + return this.internalSchema; + } +} diff --git a/src/core/server/config/schema/references/sibling_reference.ts b/src/core/server/config/schema/references/sibling_reference.ts new file mode 100644 index 00000000000000..d926763393500d --- /dev/null +++ b/src/core/server/config/schema/references/sibling_reference.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Reference } from './reference'; + +export class SiblingReference extends Reference {} diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/array_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/array_type.test.ts.snap new file mode 100644 index 00000000000000..685b13c00587e5 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/array_type.test.ts.snap @@ -0,0 +1,19 @@ +// 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 of type [string] but got [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[`includes namespace in failure when wrong item type 1`] = `"[foo-namespace.0]: expected value of type [string] but got [number]"`; + +exports[`includes namespace in failure when wrong top-level type 1`] = `"[foo-namespace]: expected value of type [array] but got [string]"`; + +exports[`object within array with required 1`] = `"[0.foo]: expected value of type [string] but got [undefined]"`; diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/boolean_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/boolean_type.test.ts.snap new file mode 100644 index 00000000000000..c3f33dc29bf50e --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/boolean_type.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [boolean] but got [undefined]"`; + +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/src/core/server/config/schema/types/__tests__/__snapshots__/byte_size_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/byte_size_type.test.ts.snap new file mode 100644 index 00000000000000..f6f45a96ca1612 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/byte_size_type.test.ts.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#defaultValue can be a ByteSizeValue 1`] = ` +ByteSizeValue { + "valueInBytes": 1024, +} +`; + +exports[`#defaultValue can be a number 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[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [ByteSize] but got [undefined]"`; + +exports[`is required by default 1`] = `"expected value of type [ByteSize] but got [undefined]"`; + +exports[`returns error when not string or positive safe integer 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-123]"`; + +exports[`returns error when not string or positive safe integer 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]"`; + +exports[`returns error when not string or positive safe integer 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]"`; + +exports[`returns error when not string or positive safe integer 4`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]"`; + +exports[`returns error when not string or positive safe integer 5`] = `"expected value of type [ByteSize] but got [Array]"`; + +exports[`returns error when not string or positive safe integer 6`] = `"expected value of type [ByteSize] but got [RegExp]"`; + +exports[`returns value by default 1`] = ` +ByteSizeValue { + "valueInBytes": 123, +} +`; diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/conditional_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/conditional_type.test.ts.snap new file mode 100644 index 00000000000000..2e0ada23eb5fd7 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/conditional_type.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`correctly handles missing references 1`] = `"[value]: expected value of type [string] but got [number]"`; + +exports[`includes namespace into failures 1`] = `"[mega-namespace.value]: expected value of type [string] but got [number]"`; + +exports[`includes namespace into failures 2`] = `"[mega-namespace.value]: expected value of type [number] but got [string]"`; + +exports[`properly handles conditionals within objects 1`] = `"[value]: expected value of type [string] but got [number]"`; + +exports[`properly handles conditionals within objects 2`] = `"[value]: expected value of type [number] but got [string]"`; + +exports[`properly handles schemas with incompatible types 1`] = `"expected value of type [string] but got [boolean]"`; + +exports[`properly handles schemas with incompatible types 2`] = `"expected value of type [boolean] but got [string]"`; + +exports[`properly validates types according chosen schema 1`] = `"value is [a] but it must have a minimum length of [2]."`; + +exports[`properly validates types according chosen schema 2`] = `"value is [ab] but it must have a maximum length of [1]."`; + +exports[`required by default 1`] = `"expected value of type [string] but got [undefined]"`; + +exports[`works with both context and sibling references 1`] = `"[value]: expected value of type [string] but got [number]"`; + +exports[`works with both context and sibling references 2`] = `"[value]: expected value of type [number] but got [string]"`; + +exports[`works within \`oneOf\` 1`] = ` +"types that failed validation: +- [0]: expected value of type [string] but got [number] +- [1]: expected value of type [array] but got [number]" +`; + +exports[`works within \`oneOf\` 2`] = ` +"types that failed validation: +- [0]: expected value of type [string] but got [boolean] +- [1]: expected value of type [array] but got [boolean]" +`; diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/duration_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/duration_type.test.ts.snap new file mode 100644 index 00000000000000..a21c28e7cc6142 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/duration_type.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#defaultValue can be a moment.Duration 1`] = `"PT1H"`; + +exports[`#defaultValue can be a number 1`] = `"PT0.6S"`; + +exports[`#defaultValue can be a string 1`] = `"PT1H"`; + +exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [moment.Duration] but got [undefined]"`; + +exports[`is required by default 1`] = `"expected value of type [moment.Duration] but got [undefined]"`; + +exports[`returns error when not string or non-safe positive integer 1`] = `"Failed to parse [-123] as time value. Value should be a safe positive integer number."`; + +exports[`returns error when not string or non-safe positive integer 2`] = `"Failed to parse [NaN] as time value. Value should be a safe positive integer number."`; + +exports[`returns error when not string or non-safe positive integer 3`] = `"Failed to parse [Infinity] as time value. Value should be a safe positive integer number."`; + +exports[`returns error when not string or non-safe positive integer 4`] = `"Failed to parse [9007199254740992] as time value. Value should be a safe positive integer number."`; + +exports[`returns error when not string or non-safe positive integer 5`] = `"expected value of type [moment.Duration] but got [Array]"`; + +exports[`returns error when not string or non-safe positive integer 6`] = `"expected value of type [moment.Duration] but got [RegExp]"`; + +exports[`returns value by default 1`] = `"PT2M3S"`; diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/literal_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/literal_type.test.ts.snap new file mode 100644 index 00000000000000..179e3e4251423f --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/literal_type.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value to equal [test] but got [foo]"`; + +exports[`returns error when not correct 1`] = `"expected value to equal [test] but got [foo]"`; + +exports[`returns error when not correct 2`] = `"expected value to equal [true] but got [false]"`; + +exports[`returns error when not correct 3`] = `"expected value to equal [test] but got [1,2,3]"`; + +exports[`returns error when not correct 4`] = `"expected value to equal [123] but got [abc]"`; diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/map_of_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/map_of_type.test.ts.snap new file mode 100644 index 00000000000000..83cca66b02450e --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/map_of_type.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`fails when not receiving expected key type 1`] = `"[name]: expected value of type [number] but got [string]"`; + +exports[`fails when not receiving expected value type 1`] = `"[name]: expected value of type [string] but got [number]"`; + +exports[`includes namespace in failure when wrong key type 1`] = `"[foo-namespace.name]: expected value of type [number] but got [string]"`; + +exports[`includes namespace in failure when wrong top-level type 1`] = `"[foo-namespace]: expected value of type [Map] or [object] but got [Array]"`; + +exports[`includes namespace in failure when wrong value type 1`] = `"[foo-namespace.name]: expected value of type [string] but got [number]"`; diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/maybe_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/maybe_type.test.ts.snap new file mode 100644 index 00000000000000..ba3ac821a97cb6 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/maybe_type.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`fails if null 1`] = `"expected value of type [string] but got [null]"`; + +exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [string] but got [null]"`; + +exports[`validates contained type 1`] = `"value is [foo] but it must have a maximum length of [1]."`; diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/number_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/number_type.test.ts.snap new file mode 100644 index 00000000000000..5d1e5fcf1ef817 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/number_type.test.ts.snap @@ -0,0 +1,17 @@ +// 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[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [number] but got [undefined]"`; + +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/src/core/server/config/schema/types/__tests__/__snapshots__/object_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/object_type.test.ts.snap new file mode 100644 index 00000000000000..5a31c3b0a7c9e8 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/object_type.test.ts.snap @@ -0,0 +1,20 @@ +// 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`] = `"[bar]: definition for this key is missing"`; + +exports[`fails if missing required value 1`] = `"[name]: expected value of type [string] but got [undefined]"`; + +exports[`handles oneOf 1`] = ` +"[key]: types that failed validation: +- [key.0]: expected value of type [string] but got [number]" +`; + +exports[`includes namespace in failure when wrong top-level type 1`] = `"[foo-namespace]: expected a plain object value, but found [Array] instead."`; + +exports[`includes namespace in failure when wrong value type 1`] = `"[foo-namespace.foo]: 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/src/core/server/config/schema/types/__tests__/__snapshots__/one_of_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/one_of_type.test.ts.snap new file mode 100644 index 00000000000000..75dfff456ebe7c --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/one_of_type.test.ts.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`fails if not matching literal 1`] = ` +"types that failed validation: +- [0]: expected value to equal [foo] but got [bar]" +`; + +exports[`fails if not matching multiple types 1`] = ` +"types 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`] = ` +"types that failed validation: +- [0]: expected value of type [string] but got [boolean]" +`; + +exports[`fails if not matching type 2`] = ` +"types that failed validation: +- [0]: expected value of type [string] but got [number]" +`; + +exports[`handles object with wrong type 1`] = ` +"types that failed validation: +- [0.age]: expected value of type [number] but got [string]" +`; + +exports[`includes namespace in failure 1`] = ` +"[foo-namespace]: types that failed validation: +- [foo-namespace.0.age]: expected value of type [number] but got [string]" +`; diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/string_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/string_type.test.ts.snap new file mode 100644 index 00000000000000..6427fa2752d40b --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/__snapshots__/string_type.test.ts.snap @@ -0,0 +1,17 @@ +// 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[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [string] but got [undefined]"`; + +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/src/core/server/config/schema/types/__tests__/array_type.test.ts b/src/core/server/config/schema/types/__tests__/array_type.test.ts new file mode 100644 index 00000000000000..f1fb124a95ede0 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/array_type.test.ts @@ -0,0 +1,136 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; + +test('returns value if it matches the type', () => { + const type = schema.arrayOf(schema.string()); + expect(type.validate(['foo', 'bar', 'baz'])).toEqual(['foo', 'bar', 'baz']); +}); + +test('fails if wrong input type', () => { + const type = schema.arrayOf(schema.string()); + expect(() => type.validate('test')).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure when wrong top-level type', () => { + const type = schema.arrayOf(schema.string()); + expect(() => type.validate('test', {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure when wrong item type', () => { + const type = schema.arrayOf(schema.string()); + expect(() => type.validate([123], {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); +}); + +test('fails if wrong type of content in array', () => { + const type = schema.arrayOf(schema.string()); + expect(() => type.validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); +}); + +test('fails if mixed types of content in array', () => { + const type = schema.arrayOf(schema.string()); + expect(() => type.validate(['foo', 'bar', true, {}])).toThrowErrorMatchingSnapshot(); +}); + +test('returns empty array if input is empty but type has default value', () => { + const type = schema.arrayOf(schema.string({ defaultValue: 'test' })); + expect(type.validate([])).toEqual([]); +}); + +test('returns empty array if input is empty even if type is required', () => { + const type = schema.arrayOf(schema.string()); + expect(type.validate([])).toEqual([]); +}); + +test('fails for null values if optional', () => { + const type = schema.arrayOf(schema.maybe(schema.string())); + expect(() => type.validate([null])).toThrowErrorMatchingSnapshot(); +}); + +test('handles default values for undefined values', () => { + const type = schema.arrayOf(schema.string({ defaultValue: 'foo' })); + expect(type.validate([undefined])).toEqual(['foo']); +}); + +test('array within array', () => { + const type = schema.arrayOf( + schema.arrayOf(schema.string(), { + maxSize: 2, + minSize: 2, + }), + { minSize: 1, maxSize: 1 } + ); + + const value = [['foo', 'bar']]; + + expect(type.validate(value)).toEqual([['foo', 'bar']]); +}); + +test('object within array', () => { + const type = schema.arrayOf( + schema.object({ + foo: schema.string({ defaultValue: 'foo' }), + }) + ); + + const value = [ + { + foo: 'test', + }, + ]; + + expect(type.validate(value)).toEqual([{ foo: 'test' }]); +}); + +test('object within array with required', () => { + const type = schema.arrayOf( + schema.object({ + foo: schema.string(), + }) + ); + + const value = [{}]; + + expect(() => type.validate(value)).toThrowErrorMatchingSnapshot(); +}); + +describe('#minSize', () => { + test('returns value when more items', () => { + expect(schema.arrayOf(schema.string(), { minSize: 1 }).validate(['foo'])).toEqual(['foo']); + }); + + test('returns error when fewer items', () => { + expect(() => + schema.arrayOf(schema.string(), { minSize: 2 }).validate(['foo']) + ).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#maxSize', () => { + test('returns value when fewer items', () => { + expect(schema.arrayOf(schema.string(), { maxSize: 2 }).validate(['foo'])).toEqual(['foo']); + }); + + test('returns error when more items', () => { + expect(() => + schema.arrayOf(schema.string(), { maxSize: 1 }).validate(['foo', 'bar']) + ).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/core/server/config/schema/types/__tests__/boolean_type.test.ts b/src/core/server/config/schema/types/__tests__/boolean_type.test.ts new file mode 100644 index 00000000000000..bfd4259af387ea --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/boolean_type.test.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; + +test('returns value by default', () => { + expect(schema.boolean().validate(true)).toBe(true); +}); + +test('is required by default', () => { + expect(() => schema.boolean().validate(undefined)).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure', () => { + expect(() => + schema.boolean().validate(undefined, {}, 'foo-namespace') + ).toThrowErrorMatchingSnapshot(); +}); + +describe('#defaultValue', () => { + test('returns default when undefined', () => { + expect(schema.boolean({ defaultValue: true }).validate(undefined)).toBe(true); + }); + + test('returns value when specified', () => { + expect(schema.boolean({ defaultValue: true }).validate(false)).toBe(false); + }); +}); + +test('returns error when not boolean', () => { + expect(() => schema.boolean().validate(123)).toThrowErrorMatchingSnapshot(); + + expect(() => schema.boolean().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => schema.boolean().validate('abc')).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/config/schema/types/__tests__/byte_size_type.test.ts b/src/core/server/config/schema/types/__tests__/byte_size_type.test.ts new file mode 100644 index 00000000000000..786b996ae5687a --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/byte_size_type.test.ts @@ -0,0 +1,103 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; +import { ByteSizeValue } from '../../byte_size_value'; + +const { byteSize } = schema; + +test('returns value by default', () => { + expect(byteSize().validate('123b')).toMatchSnapshot(); +}); + +test('is required by default', () => { + expect(() => byteSize().validate(undefined)).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure', () => { + expect(() => byteSize().validate(undefined, {}, 'foo-namespace')).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(); + }); + + test('can be a number', () => { + expect( + byteSize({ + defaultValue: 1024, + }).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 or positive safe integer', () => { + expect(() => byteSize().validate(-123)).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate(NaN)).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate(Infinity)).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate(Math.pow(2, 53))).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate(/abc/)).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/config/schema/types/__tests__/conditional_type.test.ts b/src/core/server/config/schema/types/__tests__/conditional_type.test.ts new file mode 100644 index 00000000000000..112ee874afa7b9 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/conditional_type.test.ts @@ -0,0 +1,308 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; + +test('required by default', () => { + const type = schema.conditional( + schema.contextRef('context_value_1'), + schema.contextRef('context_value_2'), + schema.string(), + schema.string() + ); + + expect(() => + type.validate(undefined, { + context_value_1: 0, + context_value_2: 0, + }) + ).toThrowErrorMatchingSnapshot(); +}); + +test('returns default', () => { + const type = schema.conditional( + schema.contextRef('context_value_1'), + schema.contextRef('context_value_2'), + schema.string(), + schema.string(), + { + defaultValue: 'unknown', + } + ); + + expect( + type.validate(undefined, { + context_value_1: 0, + context_value_2: 0, + }) + ).toEqual('unknown'); +}); + +test('properly handles nested types with defaults', () => { + const type = schema.conditional( + schema.contextRef('context_value_1'), + schema.contextRef('context_value_2'), + schema.string({ defaultValue: 'equal' }), + schema.string({ defaultValue: 'not equal' }) + ); + + expect( + type.validate(undefined, { + context_value_1: 0, + context_value_2: 0, + }) + ).toEqual('equal'); + + expect( + type.validate(undefined, { + context_value_1: 0, + context_value_2: 1, + }) + ).toEqual('not equal'); +}); + +test('properly validates types according chosen schema', () => { + const type = schema.conditional( + schema.contextRef('context_value_1'), + schema.contextRef('context_value_2'), + schema.string({ minLength: 2 }), + schema.string({ maxLength: 1 }) + ); + + expect(() => + type.validate('a', { + context_value_1: 0, + context_value_2: 0, + }) + ).toThrowErrorMatchingSnapshot(); + + expect( + type.validate('ab', { + context_value_1: 0, + context_value_2: 0, + }) + ).toEqual('ab'); + + expect(() => + type.validate('ab', { + context_value_1: 0, + context_value_2: 1, + }) + ).toThrowErrorMatchingSnapshot(); + + expect( + type.validate('a', { + context_value_1: 0, + context_value_2: 1, + }) + ).toEqual('a'); +}); + +test('properly handles schemas with incompatible types', () => { + const type = schema.conditional( + schema.contextRef('context_value_1'), + schema.contextRef('context_value_2'), + schema.string(), + schema.boolean() + ); + + expect(() => + type.validate(true, { + context_value_1: 0, + context_value_2: 0, + }) + ).toThrowErrorMatchingSnapshot(); + + expect( + type.validate('a', { + context_value_1: 0, + context_value_2: 0, + }) + ).toEqual('a'); + + expect(() => + type.validate('a', { + context_value_1: 0, + context_value_2: 1, + }) + ).toThrowErrorMatchingSnapshot(); + + expect( + type.validate(true, { + context_value_1: 0, + context_value_2: 1, + }) + ).toEqual(true); +}); + +test('properly handles conditionals within objects', () => { + const type = schema.object({ + key: schema.string(), + value: schema.conditional(schema.siblingRef('key'), 'number', schema.number(), schema.string()), + }); + + expect(() => type.validate({ key: 'string', value: 1 })).toThrowErrorMatchingSnapshot(); + + expect(type.validate({ key: 'string', value: 'a' })).toEqual({ + key: 'string', + value: 'a', + }); + + expect(() => type.validate({ key: 'number', value: 'a' })).toThrowErrorMatchingSnapshot(); + + expect(type.validate({ key: 'number', value: 1 })).toEqual({ + key: 'number', + value: 1, + }); +}); + +test('properly handled within `maybe`', () => { + const type = schema.object({ + key: schema.string(), + value: schema.maybe( + schema.conditional(schema.siblingRef('key'), 'number', schema.number(), schema.string()) + ), + }); + + expect(type.validate({ key: 'string' })).toEqual({ + key: 'string', + }); + + expect(type.validate({ key: 'number', value: 1 })).toEqual({ + key: 'number', + value: 1, + }); +}); + +test('works with both context and sibling references', () => { + const type = schema.object({ + key: schema.string(), + value: schema.conditional( + schema.siblingRef('key'), + schema.contextRef('context_key'), + schema.number(), + schema.string() + ), + }); + + expect(() => + type.validate({ key: 'string', value: 1 }, { context_key: 'number' }) + ).toThrowErrorMatchingSnapshot(); + + expect(type.validate({ key: 'string', value: 'a' }, { context_key: 'number' })).toEqual({ + key: 'string', + value: 'a', + }); + + expect(() => + type.validate({ key: 'number', value: 'a' }, { context_key: 'number' }) + ).toThrowErrorMatchingSnapshot(); + + expect(type.validate({ key: 'number', value: 1 }, { context_key: 'number' })).toEqual({ + key: 'number', + value: 1, + }); +}); + +test('includes namespace into failures', () => { + const type = schema.object({ + key: schema.string(), + value: schema.conditional(schema.siblingRef('key'), 'number', schema.number(), schema.string()), + }); + + expect(() => + type.validate({ key: 'string', value: 1 }, {}, 'mega-namespace') + ).toThrowErrorMatchingSnapshot(); + + expect(() => + type.validate({ key: 'number', value: 'a' }, {}, 'mega-namespace') + ).toThrowErrorMatchingSnapshot(); +}); + +test('correctly handles missing references', () => { + const type = schema.object({ + value: schema.conditional( + schema.siblingRef('missing-key'), + 'number', + schema.number(), + schema.string() + ), + }); + + expect(() => type.validate({ value: 1 })).toThrowErrorMatchingSnapshot(); + + expect(type.validate({ value: 'a' })).toEqual({ value: 'a' }); +}); + +test('works within `oneOf`', () => { + const type = schema.oneOf([ + schema.conditional(schema.contextRef('type'), 'number', schema.number(), schema.string()), + schema.conditional( + schema.contextRef('type'), + 'boolean', + schema.boolean(), + schema.arrayOf(schema.string()) + ), + ]); + + expect(type.validate(1, { type: 'number' })).toEqual(1); + expect(type.validate('1', { type: 'string' })).toEqual('1'); + expect(type.validate(true, { type: 'boolean' })).toEqual(true); + expect(type.validate(['a', 'b'], { type: 'array' })).toEqual(['a', 'b']); + + expect(() => type.validate(1, { type: 'string' })).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(true, { type: 'string' })).toThrowErrorMatchingSnapshot(); +}); + +describe('#validate', () => { + test('is called after all content is processed', () => { + const mockValidate = jest.fn(); + + const type = schema.object( + { + key: schema.string(), + value: schema.conditional( + schema.siblingRef('key'), + 'number', + schema.number({ defaultValue: 100 }), + schema.string({ defaultValue: 'some-string' }) + ), + }, + { + validate: mockValidate, + } + ); + + type.validate({ key: 'number' }); + + expect(mockValidate).toHaveBeenCalledWith({ + key: 'number', + value: 100, + }); + + mockValidate.mockClear(); + + type.validate({ key: 'not-number' }); + + expect(mockValidate).toHaveBeenCalledWith({ + key: 'not-number', + value: 'some-string', + }); + }); +}); diff --git a/src/core/server/config/schema/types/__tests__/duration_type.test.ts b/src/core/server/config/schema/types/__tests__/duration_type.test.ts new file mode 100644 index 00000000000000..0c1d7e4dd8e508 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/duration_type.test.ts @@ -0,0 +1,75 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { duration as momentDuration } from 'moment'; +import { schema } from '../..'; + +const { duration } = schema; + +test('returns value by default', () => { + expect(duration().validate('123s')).toMatchSnapshot(); +}); + +test('is required by default', () => { + expect(() => duration().validate(undefined)).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure', () => { + expect(() => duration().validate(undefined, {}, 'foo-namespace')).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('can be a number', () => { + expect( + duration({ + defaultValue: 600, + }).validate(undefined) + ).toMatchSnapshot(); + }); +}); + +test('returns error when not string or non-safe positive integer', () => { + expect(() => duration().validate(-123)).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate(NaN)).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate(Infinity)).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate(Math.pow(2, 53))).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate(/abc/)).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/config/schema/types/__tests__/literal_type.test.ts b/src/core/server/config/schema/types/__tests__/literal_type.test.ts new file mode 100644 index 00000000000000..4d590200c1ccf7 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/literal_type.test.ts @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; + +const { literal } = schema; + +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 correct', () => { + 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(); +}); + +test('includes namespace in failure', () => { + expect(() => literal('test').validate('foo', {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/config/schema/types/__tests__/map_of_type.test.ts b/src/core/server/config/schema/types/__tests__/map_of_type.test.ts new file mode 100644 index 00000000000000..ed4e12f162c59f --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/map_of_type.test.ts @@ -0,0 +1,110 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; + +test('handles object as input', () => { + const type = schema.mapOf(schema.string(), schema.string()); + const value = { + name: 'foo', + }; + const expected = new Map([['name', 'foo']]); + + expect(type.validate(value)).toEqual(expected); +}); + +test('fails when not receiving expected value type', () => { + const type = schema.mapOf(schema.string(), schema.string()); + const value = { + name: 123, + }; + + expect(() => type.validate(value)).toThrowErrorMatchingSnapshot(); +}); + +test('fails when not receiving expected key type', () => { + const type = schema.mapOf(schema.number(), schema.string()); + const value = { + name: 'foo', + }; + + expect(() => type.validate(value)).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure when wrong top-level type', () => { + const type = schema.mapOf(schema.string(), schema.string()); + expect(() => type.validate([], {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure when wrong value type', () => { + const type = schema.mapOf(schema.string(), schema.string()); + const value = { + name: 123, + }; + + expect(() => type.validate(value, {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure when wrong key type', () => { + const type = schema.mapOf(schema.number(), schema.string()); + const value = { + name: 'foo', + }; + + expect(() => type.validate(value, {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); +}); + +test('returns default value if undefined', () => { + const obj = new Map([['foo', 'bar']]); + + const type = schema.mapOf(schema.string(), schema.string(), { + defaultValue: obj, + }); + + expect(type.validate(undefined)).toEqual(obj); +}); + +test('mapOf within mapOf', () => { + const type = schema.mapOf(schema.string(), schema.mapOf(schema.string(), schema.number())); + const value = { + foo: { + bar: 123, + }, + }; + const expected = new Map([['foo', new Map([['bar', 123]])]]); + + expect(type.validate(value)).toEqual(expected); +}); + +test('object within mapOf', () => { + const type = schema.mapOf( + schema.string(), + schema.object({ + bar: schema.number(), + }) + ); + const value = { + foo: { + bar: 123, + }, + }; + const expected = new Map([['foo', { bar: 123 }]]); + + expect(type.validate(value)).toEqual(expected); +}); diff --git a/src/core/server/config/schema/types/__tests__/maybe_type.test.ts b/src/core/server/config/schema/types/__tests__/maybe_type.test.ts new file mode 100644 index 00000000000000..950987763baf19 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/maybe_type.test.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; + +test('returns value if specified', () => { + const type = schema.maybe(schema.string()); + expect(type.validate('test')).toEqual('test'); +}); + +test('returns undefined if undefined', () => { + const type = schema.maybe(schema.string()); + expect(type.validate(undefined)).toEqual(undefined); +}); + +test('returns undefined even if contained type has a default value', () => { + const type = schema.maybe( + schema.string({ + defaultValue: 'abc', + }) + ); + + expect(type.validate(undefined)).toEqual(undefined); +}); + +test('validates contained type', () => { + const type = schema.maybe(schema.string({ maxLength: 1 })); + + expect(() => type.validate('foo')).toThrowErrorMatchingSnapshot(); +}); + +test('fails if null', () => { + const type = schema.maybe(schema.string()); + expect(() => type.validate(null)).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure', () => { + const type = schema.maybe(schema.string()); + expect(() => type.validate(null, {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/config/schema/types/__tests__/number_type.test.ts b/src/core/server/config/schema/types/__tests__/number_type.test.ts new file mode 100644 index 00000000000000..dd6be2631d28cf --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/number_type.test.ts @@ -0,0 +1,84 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; + +test('returns value by default', () => { + expect(schema.number().validate(4)).toBe(4); +}); + +test('handles numeric strings with ints', () => { + expect(schema.number().validate('4')).toBe(4); +}); + +test('handles numeric strings with floats', () => { + expect(schema.number().validate('4.23')).toBe(4.23); +}); + +test('fails if number is `NaN`', () => { + expect(() => schema.number().validate(NaN)).toThrowErrorMatchingSnapshot(); +}); + +test('is required by default', () => { + expect(() => schema.number().validate(undefined)).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure', () => { + expect(() => + schema.number().validate(undefined, {}, 'foo-namespace') + ).toThrowErrorMatchingSnapshot(); +}); + +describe('#min', () => { + test('returns value when larger number', () => { + expect(schema.number({ min: 2 }).validate(3)).toBe(3); + }); + + test('returns error when smaller number', () => { + expect(() => schema.number({ min: 4 }).validate(3)).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#max', () => { + test('returns value when smaller number', () => { + expect(schema.number({ max: 4 }).validate(3)).toBe(3); + }); + + test('returns error when larger number', () => { + expect(() => schema.number({ max: 2 }).validate(3)).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#defaultValue', () => { + test('returns default when number is undefined', () => { + expect(schema.number({ defaultValue: 2 }).validate(undefined)).toBe(2); + }); + + test('returns value when specified', () => { + expect(schema.number({ defaultValue: 2 }).validate(3)).toBe(3); + }); +}); + +test('returns error when not number or numeric string', () => { + expect(() => schema.number().validate('test')).toThrowErrorMatchingSnapshot(); + + expect(() => schema.number().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => schema.number().validate(/abc/)).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/config/schema/types/__tests__/object_type.test.ts b/src/core/server/config/schema/types/__tests__/object_type.test.ts new file mode 100644 index 00000000000000..ec54528c292a05 --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/object_type.test.ts @@ -0,0 +1,201 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; + +test('returns value by default', () => { + const type = schema.object({ + name: schema.string(), + }); + const value = { + name: 'test', + }; + + expect(type.validate(value)).toEqual({ name: 'test' }); +}); + +test('fails if missing required value', () => { + const type = schema.object({ + name: schema.string(), + }); + const value = {}; + + expect(() => type.validate(value)).toThrowErrorMatchingSnapshot(); +}); + +test('returns value if undefined string with default', () => { + const type = schema.object({ + name: schema.string({ defaultValue: 'test' }), + }); + const value = {}; + + expect(type.validate(value)).toEqual({ name: 'test' }); +}); + +test('fails if key does not exist in schema', () => { + const type = schema.object({ + foo: schema.string(), + }); + const value = { + bar: 'baz', + foo: 'bar', + }; + + expect(() => type.validate(value)).toThrowErrorMatchingSnapshot(); +}); + +test('defined object within object', () => { + const type = schema.object({ + foo: schema.object({ + bar: schema.string({ defaultValue: 'hello world' }), + }), + }); + + expect(type.validate({ foo: {} })).toEqual({ + foo: { + bar: 'hello world', + }, + }); +}); + +test('undefined object within object', () => { + const type = schema.object({ + foo: schema.object({ + bar: schema.string({ defaultValue: 'hello world' }), + }), + }); + + expect(type.validate({})).toEqual({ + foo: { + bar: 'hello world', + }, + }); +}); + +test('object within object with required', () => { + const type = schema.object({ + foo: schema.object({ + bar: schema.string(), + }), + }); + const value = { foo: {} }; + + expect(() => type.validate(value)).toThrowErrorMatchingSnapshot(); +}); + +describe('#validate', () => { + test('is called after all content is processed', () => { + const mockValidate = jest.fn(); + + const type = schema.object( + { + foo: schema.object({ + bar: schema.string({ defaultValue: 'baz' }), + }), + }, + { + validate: mockValidate, + } + ); + + type.validate({ foo: {} }); + + expect(mockValidate).toHaveBeenCalledWith({ + foo: { + bar: 'baz', + }, + }); + }); +}); + +test('called with wrong type', () => { + const type = schema.object({}); + + expect(() => type.validate('foo')).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(123)).toThrowErrorMatchingSnapshot(); +}); + +test('handles oneOf', () => { + const type = schema.object({ + key: schema.oneOf([schema.string()]), + }); + + expect(type.validate({ key: 'foo' })).toEqual({ key: 'foo' }); + expect(() => type.validate({ key: 123 })).toThrowErrorMatchingSnapshot(); +}); + +test('handles references', () => { + const type = schema.object({ + context: schema.string({ + defaultValue: schema.contextRef('context_value'), + }), + key: schema.string(), + value: schema.string({ defaultValue: schema.siblingRef('key') }), + }); + + expect(type.validate({ key: 'key#1' }, { context_value: 'context#1' })).toEqual({ + context: 'context#1', + key: 'key#1', + value: 'key#1', + }); + expect(type.validate({ key: 'key#1', value: 'value#1' })).toEqual({ + key: 'key#1', + value: 'value#1', + }); +}); + +test('handles conditionals', () => { + const type = schema.object({ + key: schema.string(), + value: schema.conditional( + schema.siblingRef('key'), + 'some-key', + schema.string({ defaultValue: 'some-value' }), + schema.string({ defaultValue: 'unknown-value' }) + ), + }); + + expect(type.validate({ key: 'some-key' })).toEqual({ + key: 'some-key', + value: 'some-value', + }); + expect(type.validate({ key: 'another-key' })).toEqual({ + key: 'another-key', + value: 'unknown-value', + }); +}); + +test('includes namespace in failure when wrong top-level type', () => { + const type = schema.object({ + foo: schema.string(), + }); + + expect(() => type.validate([], {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure when wrong value type', () => { + const type = schema.object({ + foo: schema.string(), + }); + const value = { + foo: 123, + }; + + expect(() => type.validate(value, {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/config/schema/types/__tests__/one_of_type.test.ts b/src/core/server/config/schema/types/__tests__/one_of_type.test.ts new file mode 100644 index 00000000000000..e2f0f9688544af --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/one_of_type.test.ts @@ -0,0 +1,127 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; + +test('handles string', () => { + expect(schema.oneOf([schema.string()]).validate('test')).toBe('test'); +}); + +test('handles string with default', () => { + const type = schema.oneOf([schema.string()], { + defaultValue: 'test', + }); + + expect(type.validate(undefined)).toBe('test'); +}); + +test('handles number', () => { + expect(schema.oneOf([schema.number()]).validate(123)).toBe(123); +}); + +test('handles number with default', () => { + const type = schema.oneOf([schema.number()], { + defaultValue: 123, + }); + + expect(type.validate(undefined)).toBe(123); +}); + +test('handles literal', () => { + const type = schema.oneOf([schema.literal('foo')]); + + expect(type.validate('foo')).toBe('foo'); +}); + +test('handles literal with default', () => { + const type = schema.oneOf([schema.literal('foo')], { + defaultValue: 'foo', + }); + + expect(type.validate(undefined)).toBe('foo'); +}); + +test('handles multiple literals with default', () => { + const type = schema.oneOf([schema.literal('foo'), schema.literal('bar')], { + defaultValue: 'bar', + }); + + expect(type.validate('foo')).toBe('foo'); + expect(type.validate(undefined)).toBe('bar'); +}); + +test('handles object', () => { + const type = schema.oneOf([schema.object({ name: schema.string() })]); + + expect(type.validate({ name: 'foo' })).toEqual({ name: 'foo' }); +}); + +test('handles object with wrong type', () => { + const type = schema.oneOf([schema.object({ age: schema.number() })]); + + expect(() => type.validate({ age: 'foo' })).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure', () => { + const type = schema.oneOf([schema.object({ age: schema.number() })]); + + expect(() => type.validate({ age: 'foo' }, {}, 'foo-namespace')).toThrowErrorMatchingSnapshot(); +}); + +test('handles multiple objects with same key', () => { + const type = schema.oneOf([ + schema.object({ age: schema.string() }), + schema.object({ age: schema.number() }), + ]); + + expect(type.validate({ age: 'foo' })).toEqual({ age: 'foo' }); +}); + +test('handles multiple types', () => { + const type = schema.oneOf([schema.string(), schema.number()]); + + expect(type.validate('test')).toBe('test'); + expect(type.validate(123)).toBe(123); +}); + +test('handles maybe', () => { + const type = schema.maybe(schema.oneOf([schema.maybe(schema.string())])); + + expect(type.validate(undefined)).toBe(undefined); + expect(type.validate('test')).toBe('test'); +}); + +test('fails if not matching type', () => { + const type = schema.oneOf([schema.string()]); + + expect(() => type.validate(false)).toThrowErrorMatchingSnapshot(); + expect(() => type.validate(123)).toThrowErrorMatchingSnapshot(); +}); + +test('fails if not matching multiple types', () => { + const type = schema.oneOf([schema.string(), schema.number()]); + + expect(() => type.validate(false)).toThrowErrorMatchingSnapshot(); +}); + +test('fails if not matching literal', () => { + const type = schema.oneOf([schema.literal('foo')]); + + expect(() => type.validate('bar')).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/config/schema/types/__tests__/string_type.test.ts b/src/core/server/config/schema/types/__tests__/string_type.test.ts new file mode 100644 index 00000000000000..4ed472d10930ae --- /dev/null +++ b/src/core/server/config/schema/types/__tests__/string_type.test.ts @@ -0,0 +1,108 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '../..'; + +test('returns value is string and defined', () => { + expect(schema.string().validate('test')).toBe('test'); +}); + +test('is required by default', () => { + expect(() => schema.string().validate(undefined)).toThrowErrorMatchingSnapshot(); +}); + +test('includes namespace in failure', () => { + expect(() => + schema.string().validate(undefined, {}, 'foo-namespace') + ).toThrowErrorMatchingSnapshot(); +}); + +describe('#minLength', () => { + test('returns value when longer string', () => { + expect(schema.string({ minLength: 2 }).validate('foo')).toBe('foo'); + }); + + test('returns error when shorter string', () => { + expect(() => schema.string({ minLength: 4 }).validate('foo')).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#maxLength', () => { + test('returns value when shorter string', () => { + expect(schema.string({ maxLength: 4 }).validate('foo')).toBe('foo'); + }); + + test('returns error when longer string', () => { + expect(() => schema.string({ maxLength: 2 }).validate('foo')).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('#defaultValue', () => { + test('returns default when string is undefined', () => { + expect(schema.string({ defaultValue: 'foo' }).validate(undefined)).toBe('foo'); + }); + + test('returns value when specified', () => { + expect(schema.string({ defaultValue: 'foo' }).validate('bar')).toBe('bar'); + }); + + test('returns value from context when context reference is specified', () => { + expect( + schema.string({ defaultValue: schema.contextRef('some_value') }).validate(undefined, { + some_value: 'some', + }) + ).toBe('some'); + }); +}); + +describe('#validate', () => { + test('is called with input value', () => { + let calledWith; + + const validator = (val: any) => { + calledWith = val; + }; + + schema.string({ validate: validator }).validate('test'); + + expect(calledWith).toBe('test'); + }); + + test('is not called with default value in no input', () => { + const validate = jest.fn(); + + schema.string({ validate, defaultValue: 'foo' }).validate(undefined); + + expect(validate).not.toHaveBeenCalled(); + }); + + test('throws when returns string', () => { + const validate = () => 'validator failure'; + + expect(() => schema.string({ validate }).validate('foo')).toThrowErrorMatchingSnapshot(); + }); +}); + +test('returns error when not string', () => { + expect(() => schema.string().validate(123)).toThrowErrorMatchingSnapshot(); + + expect(() => schema.string().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); + + expect(() => schema.string().validate(/abc/)).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/config/schema/types/any_type.ts b/src/core/server/config/schema/types/any_type.ts new file mode 100644 index 00000000000000..53a32c58f001bf --- /dev/null +++ b/src/core/server/config/schema/types/any_type.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Type } from './type'; + +export type AnyType = Type; diff --git a/src/core/server/config/schema/types/array_type.ts b/src/core/server/config/schema/types/array_type.ts new file mode 100644 index 00000000000000..73f2d0e6140565 --- /dev/null +++ b/src/core/server/config/schema/types/array_type.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { internals } from '../internals'; +import { Type, TypeOptions } from './type'; + +export type ArrayOptions = TypeOptions & { + minSize?: number; + maxSize?: number; +}; + +export class ArrayType extends Type { + constructor(type: Type, options: ArrayOptions = {}) { + let schema = internals + .array() + .items(type.getSchema().optional()) + .sparse(); + + if (options.minSize !== undefined) { + schema = schema.min(options.minSize); + } + + if (options.maxSize !== undefined) { + schema = schema.max(options.maxSize); + } + + super(schema, options); + } + + protected handleError(type: string, { limit, reason, value }: Record) { + switch (type) { + case 'any.required': + case 'array.base': + return `expected value of type [array] but got [${typeDetect(value)}]`; + case 'array.min': + return `array size is [${value.length}], but cannot be smaller than [${limit}]`; + case 'array.max': + return `array size is [${value.length}], but cannot be greater than [${limit}]`; + case 'array.includesOne': + return reason[0]; + } + } +} diff --git a/src/core/server/config/schema/types/boolean_type.ts b/src/core/server/config/schema/types/boolean_type.ts new file mode 100644 index 00000000000000..bfbe3a94212231 --- /dev/null +++ b/src/core/server/config/schema/types/boolean_type.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { internals } from '../internals'; +import { Type, TypeOptions } from './type'; + +export class BooleanType extends Type { + constructor(options?: TypeOptions) { + super(internals.boolean(), options); + } + + protected handleError(type: string, { value }: Record) { + if (type === 'any.required' || type === 'boolean.base') { + return `expected value of type [boolean] but got [${typeDetect(value)}]`; + } + } +} diff --git a/src/core/server/config/schema/types/byte_size_type.ts b/src/core/server/config/schema/types/byte_size_type.ts new file mode 100644 index 00000000000000..4833de7ecf15f9 --- /dev/null +++ b/src/core/server/config/schema/types/byte_size_type.ts @@ -0,0 +1,73 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { ByteSizeValue, ensureByteSizeValue } from '../byte_size_value'; +import { SchemaTypeError } from '../errors'; +import { internals } from '../internals'; +import { Type } from './type'; + +export interface ByteSizeOptions { + // we need to special-case defaultValue as we want to handle string inputs too + validate?: (value: ByteSizeValue) => string | void; + defaultValue?: ByteSizeValue | string | number; + min?: ByteSizeValue | string | number; + max?: ByteSizeValue | string | number; +} + +export class ByteSizeType extends Type { + constructor(options: ByteSizeOptions = {}) { + let schema = internals.bytes(); + + if (options.min !== undefined) { + schema = schema.min(options.min); + } + + if (options.max !== undefined) { + schema = schema.max(options.max); + } + + super(schema, { + defaultValue: ensureByteSizeValue(options.defaultValue), + validate: options.validate, + }); + } + + protected handleError( + type: string, + { limit, message, value }: Record, + path: string[] + ) { + switch (type) { + case 'any.required': + case 'bytes.base': + return `expected value of type [ByteSize] but got [${typeDetect(value)}]`; + case 'bytes.parse': + return new SchemaTypeError(message, path); + case 'bytes.min': + return `Value is [${value.toString()}] ([${value.toString( + 'b' + )}]) but it must be equal to or greater than [${limit.toString()}]`; + case 'bytes.max': + return `Value is [${value.toString()}] ([${value.toString( + 'b' + )}]) but it must be equal to or less than [${limit.toString()}]`; + } + } +} diff --git a/src/core/server/config/schema/types/conditional_type.ts b/src/core/server/config/schema/types/conditional_type.ts new file mode 100644 index 00000000000000..b80c08776767c8 --- /dev/null +++ b/src/core/server/config/schema/types/conditional_type.ts @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { internals } from '../internals'; +import { Reference } from '../references'; +import { Type, TypeOptions } from './type'; + +export class ConditionalType extends Type { + constructor( + leftOperand: Reference, + rightOperand: Reference | A, + equalType: Type, + notEqualType: Type, + options?: TypeOptions + ) { + const schema = internals.when(leftOperand.getSchema(), { + is: Reference.isReference(rightOperand) ? rightOperand.getSchema() : rightOperand, + otherwise: notEqualType.getSchema(), + then: equalType.getSchema(), + }); + + super(schema, options); + } + + protected handleError(type: string, { value }: Record) { + if (type === 'any.required') { + return `expected at least one defined value but got [${typeDetect(value)}]`; + } + } +} diff --git a/src/core/server/config/schema/types/duration_type.ts b/src/core/server/config/schema/types/duration_type.ts new file mode 100644 index 00000000000000..4d5ed952b7296a --- /dev/null +++ b/src/core/server/config/schema/types/duration_type.ts @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { Duration, ensureDuration } from '../duration'; +import { SchemaTypeError } from '../errors'; +import { internals } from '../internals'; +import { Type } from './type'; + +export interface DurationOptions { + // we need to special-case defaultValue as we want to handle string inputs too + validate?: (value: Duration) => string | void; + defaultValue?: Duration | string | number; +} + +export class DurationType extends Type { + constructor(options: DurationOptions = {}) { + super(internals.duration(), { + ...options, + defaultValue: ensureDuration(options.defaultValue), + }); + } + + protected handleError(type: string, { message, value }: Record, path: string[]) { + switch (type) { + case 'any.required': + case 'duration.base': + return `expected value of type [moment.Duration] but got [${typeDetect(value)}]`; + case 'duration.parse': + return new SchemaTypeError(message, path); + } + } +} diff --git a/src/core/server/config/schema/types/index.ts b/src/core/server/config/schema/types/index.ts new file mode 100644 index 00000000000000..6e2f28e8b7c3f4 --- /dev/null +++ b/src/core/server/config/schema/types/index.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { Type, TypeOptions } from './type'; +export { AnyType } from './any_type'; +export { ArrayOptions, ArrayType } from './array_type'; +export { BooleanType } from './boolean_type'; +export { ByteSizeOptions, ByteSizeType } from './byte_size_type'; +export { ConditionalType } from './conditional_type'; +export { DurationOptions, DurationType } from './duration_type'; +export { LiteralType } from './literal_type'; +export { MaybeType } from './maybe_type'; +export { MapOfOptions, MapOfType } from './map_type'; +export { NumberOptions, NumberType } from './number_type'; +export { ObjectType, Props, TypeOf } from './object_type'; +export { StringOptions, StringType } from './string_type'; +export { UnionType } from './union_type'; diff --git a/src/core/server/config/schema/types/literal_type.ts b/src/core/server/config/schema/types/literal_type.ts new file mode 100644 index 00000000000000..d53d08238cee57 --- /dev/null +++ b/src/core/server/config/schema/types/literal_type.ts @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { internals } from '../internals'; +import { Type } from './type'; + +export class LiteralType extends Type { + constructor(value: T) { + super(internals.any(), { + // Before v13.3.0 Joi.any().value() didn't provide raw value if validation + // fails, so to display this value in error message we should provide + // custom validation function. Once we upgrade Joi, we'll be able to use + // `value()` with custom `any.allowOnly` error handler instead. + validate(valueToValidate) { + if (valueToValidate !== value) { + return `expected value to equal [${value}] but got [${valueToValidate}]`; + } + }, + }); + } +} diff --git a/src/core/server/config/schema/types/map_type.ts b/src/core/server/config/schema/types/map_type.ts new file mode 100644 index 00000000000000..2773728f7478dc --- /dev/null +++ b/src/core/server/config/schema/types/map_type.ts @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { SchemaTypeError } from '../errors'; +import { internals } from '../internals'; +import { Type, TypeOptions } from './type'; + +export type MapOfOptions = TypeOptions>; + +export class MapOfType extends Type> { + constructor(keyType: Type, valueType: Type, options: MapOfOptions = {}) { + const defaultValue = options.defaultValue; + const schema = internals.map().entries(keyType.getSchema(), valueType.getSchema()); + + super(schema, { + ...options, + // Joi clones default values with `Hoek.clone`, and there is bug in cloning + // of Map/Set/Promise/Error: https://github.com/hapijs/hoek/issues/228. + // The only way to avoid cloning and hence the bug is to use function for + // default value instead. + defaultValue: defaultValue instanceof Map ? () => defaultValue : defaultValue, + }); + } + + protected handleError( + type: string, + { entryKey, reason, value }: Record, + path: string[] + ) { + switch (type) { + case 'any.required': + case 'map.base': + return `expected value of type [Map] or [object] but got [${typeDetect(value)}]`; + case 'map.key': + case 'map.value': + const childPathWithIndex = reason.path.slice(); + childPathWithIndex.splice(path.length, 0, entryKey.toString()); + + return new SchemaTypeError(reason.message, childPathWithIndex); + } + } +} diff --git a/src/core/server/config/schema/types/maybe_type.ts b/src/core/server/config/schema/types/maybe_type.ts new file mode 100644 index 00000000000000..06a93691102036 --- /dev/null +++ b/src/core/server/config/schema/types/maybe_type.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Type } from './type'; + +export class MaybeType extends Type { + constructor(type: Type) { + super( + type + .getSchema() + .optional() + .default() + ); + } +} diff --git a/src/core/server/config/schema/types/number_type.ts b/src/core/server/config/schema/types/number_type.ts new file mode 100644 index 00000000000000..ada4d1909c9178 --- /dev/null +++ b/src/core/server/config/schema/types/number_type.ts @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { internals } from '../internals'; +import { Type, TypeOptions } from './type'; + +export type NumberOptions = TypeOptions & { + min?: number; + max?: number; +}; + +export class NumberType extends Type { + constructor(options: NumberOptions = {}) { + let schema = internals.number(); + if (options.min !== undefined) { + schema = schema.min(options.min); + } + + if (options.max !== undefined) { + schema = schema.max(options.max); + } + + super(schema, options); + } + + protected handleError(type: string, { limit, value }: Record) { + switch (type) { + case 'any.required': + case 'number.base': + return `expected value of type [number] but got [${typeDetect(value)}]`; + case 'number.min': + return `Value is [${value}] but it must be equal to or greater than [${limit}].`; + case 'number.max': + return `Value is [${value}] but it must be equal to or lower than [${limit}].`; + } + } +} diff --git a/src/core/server/config/schema/types/object_type.ts b/src/core/server/config/schema/types/object_type.ts new file mode 100644 index 00000000000000..7e9e8a796cc72f --- /dev/null +++ b/src/core/server/config/schema/types/object_type.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { AnySchema, internals } from '../internals'; +import { AnyType } from './any_type'; +import { Type, TypeOptions } from './type'; + +export type Props = Record; + +export type TypeOf = RT['type']; + +// Because of https://github.com/Microsoft/TypeScript/issues/14041 +// this might not have perfect _rendering_ output, but it will be typed. + +export type ObjectResultType