diff --git a/src/cli/cluster/base_path_proxy.js b/src/cli/cluster/base_path_proxy.js index 80166e2c63cb5..32e571901cf73 100644 --- a/src/cli/cluster/base_path_proxy.js +++ b/src/cli/cluster/base_path_proxy.js @@ -6,7 +6,7 @@ import { map as promiseMap, fromNode } from 'bluebird'; import { Agent as HttpsAgent } from 'https'; import { readFileSync } from 'fs'; -import Config from '../../server/config/config'; +import { Config } from '../../server/config/config'; import setupConnection from '../../server/http/setup_connection'; import registerHapiPlugins from '../../server/http/register_hapi_plugins'; import setupLogging from '../../server/logging'; diff --git a/src/core_plugins/console/index.js b/src/core_plugins/console/index.js index 27faf5fe3fe96..688481f48548d 100644 --- a/src/core_plugins/console/index.js +++ b/src/core_plugins/console/index.js @@ -31,6 +31,14 @@ export default function (kibana) { id: 'console', require: [ 'elasticsearch' ], + isEnabled(config) { + // console must be disabled when tribe mode is configured + return ( + config.get('console.enabled') && + !config.get('elasticsearch.tribe.url') + ); + }, + config: function (Joi) { return Joi.object({ enabled: Joi.boolean().default(true), @@ -115,7 +123,7 @@ export default function (kibana) { } }); - const testApp = kibana.uiExports.apps.hidden.byId['sense-tests']; + const testApp = server.getHiddenUiAppById('sense-tests'); if (testApp) { server.route({ path: '/app/sense-tests', diff --git a/src/core_plugins/dev_mode/index.js b/src/core_plugins/dev_mode/index.js index f06eeb323f246..b44a3c52d204c 100644 --- a/src/core_plugins/dev_mode/index.js +++ b/src/core_plugins/dev_mode/index.js @@ -1,6 +1,14 @@ export default (kibana) => { - if (!kibana.config.get('env.dev')) return; return new kibana.Plugin({ + id: 'dev_mode', + + isEnabled(config) { + return ( + config.get('env.dev') && + config.get('dev_mode.enabled') + ); + }, + uiExports: { spyModes: [ 'plugins/dev_mode/vis_debug_spy_panel' diff --git a/src/core_plugins/kibana/server/lib/manage_uuid.js b/src/core_plugins/kibana/server/lib/manage_uuid.js index 355e3e4a97044..83bf4fd06d6d0 100644 --- a/src/core_plugins/kibana/server/lib/manage_uuid.js +++ b/src/core_plugins/kibana/server/lib/manage_uuid.js @@ -17,7 +17,7 @@ export default async function manageUuid(server) { return result.toString(FILE_ENCODING); } catch (err) { if (err.code === 'ENOENT') { - // non-existant uuid file is ok + // non-existent uuid file is ok return false; } server.log(['error', 'read-uuid'], err); diff --git a/src/core_plugins/state_session_storage_redirect/index.js b/src/core_plugins/state_session_storage_redirect/index.js index ba0fcfa844f24..37e37ba3928e3 100644 --- a/src/core_plugins/state_session_storage_redirect/index.js +++ b/src/core_plugins/state_session_storage_redirect/index.js @@ -6,7 +6,7 @@ export default function (kibana) { title: 'Redirecting', id: 'stateSessionStorageRedirect', main: 'plugins/state_session_storage_redirect', - listed: false, + hidden: true, } } }); diff --git a/src/core_plugins/tests_bundle/index.js b/src/core_plugins/tests_bundle/index.js index 223ef39f9536e..4852c649719ab 100644 --- a/src/core_plugins/tests_bundle/index.js +++ b/src/core_plugins/tests_bundle/index.js @@ -1,7 +1,10 @@ import { union } from 'lodash'; -import findSourceFiles from './find_source_files'; + import { fromRoot } from '../../utils'; +import findSourceFiles from './find_source_files'; +import { createTestEntryTemplate } from './tests_entry_template'; + export default (kibana) => { return new kibana.Plugin({ config: (Joi) => { @@ -13,9 +16,18 @@ export default (kibana) => { }, uiExports: { - bundle: async (UiBundle, env, apps, plugins) => { + async __bundleProvider__(kbnServer) { let modules = []; - const config = kibana.config; + + const { + config, + uiApps, + uiBundles, + plugins, + uiExports: { + uiSettingDefaults = {} + } + } = kbnServer; const testGlobs = [ 'src/ui/public/**/*.js', @@ -26,20 +38,25 @@ export default (kibana) => { if (testingPluginIds) { testGlobs.push('!src/ui/public/**/__tests__/**/*'); testingPluginIds.split(',').forEach((pluginId) => { - const plugin = plugins.byId[pluginId]; - if (!plugin) throw new Error('Invalid testingPluginId :: unknown plugin ' + pluginId); + const plugin = plugins + .find(plugin => plugin.id === pluginId); + + if (!plugin) { + throw new Error('Invalid testingPluginId :: unknown plugin ' + pluginId); + } // add the modules from all of this plugins apps - for (const app of plugin.apps) { - modules = union(modules, app.getModules()); + for (const app of uiApps) { + if (app.getPluginId() === pluginId) { + modules = union(modules, app.getModules()); + } } testGlobs.push(`${plugin.publicDir}/**/__tests__/**/*.js`); }); } else { - // add the modules from all of the apps - for (const app of apps) { + for (const app of uiApps) { modules = union(modules, app.getModules()); } @@ -52,24 +69,17 @@ export default (kibana) => { for (const f of testFiles) modules.push(f); if (config.get('tests_bundle.instrument')) { - env.addPostLoader({ + uiBundles.addPostLoader({ test: /\.js$/, exclude: /[\/\\](__tests__|node_modules|bower_components|webpackShims)[\/\\]/, - loader: 'istanbul-instrumenter' + loader: 'istanbul-instrumenter-loader' }); } - env.defaultUiSettings = plugins.kbnServer.uiExports.consumers - // find the first uiExportsConsumer that has a getUiSettingDefaults method - // See src/ui/ui_settings/ui_exports_consumer.js - .find(consumer => typeof consumer.getUiSettingDefaults === 'function') - .getUiSettingDefaults(); - - return new UiBundle({ + uiBundles.add({ id: 'tests', - modules: modules, - template: require('./tests_entry_template'), - env: env + modules, + template: createTestEntryTemplate(uiSettingDefaults), }); }, diff --git a/src/core_plugins/tests_bundle/tests_entry_template.js b/src/core_plugins/tests_bundle/tests_entry_template.js index dfe1157c071fe..76f0407f56add 100644 --- a/src/core_plugins/tests_bundle/tests_entry_template.js +++ b/src/core_plugins/tests_bundle/tests_entry_template.js @@ -1,24 +1,12 @@ import { esTestConfig } from '../../test_utils/es'; -export default function ({ env, bundle }) { - - const pluginSlug = env.pluginInfo.sort() - .map(p => ' * - ' + p) - .join('\n'); - - const requires = bundle.modules - .map(m => `require(${JSON.stringify(m)});`) - .join('\n'); - - return ` +export const createTestEntryTemplate = (defaultUiSettings) => (bundle) => ` /** * Test entry file * * This is programatically created and updated, do not modify * - * context: ${JSON.stringify(env.context)} - * includes code from: -${pluginSlug} + * context: ${bundle.getContext()} * */ @@ -47,14 +35,12 @@ window.__KBN__ = { } }, uiSettings: { - defaults: ${JSON.stringify(env.defaultUiSettings, null, 2).split('\n').join('\n ')}, + defaults: ${JSON.stringify(defaultUiSettings, null, 2).split('\n').join('\n ')}, user: {} } }; require('ui/test_harness'); -${requires} +${bundle.getRequires().join('\n')} require('ui/test_harness').bootstrap(/* go! */); `; - -} diff --git a/src/core_plugins/timelion/index.js b/src/core_plugins/timelion/index.js index 109abcb59dcfc..98f6121e55611 100644 --- a/src/core_plugins/timelion/index.js +++ b/src/core_plugins/timelion/index.js @@ -1,15 +1,4 @@ export default function (kibana) { - let mainFile = 'plugins/timelion/app'; - - const ownDescriptor = Object.getOwnPropertyDescriptor(kibana, 'autoload'); - const protoDescriptor = Object.getOwnPropertyDescriptor(kibana.constructor.prototype, 'autoload'); - const descriptor = ownDescriptor || protoDescriptor || {}; - if (descriptor.get) { - // the autoload list has been replaced with a getter that complains about - // improper access, bypass that getter by seeing if it is defined - mainFile = 'plugins/timelion/app_with_autoload'; - } - return new kibana.Plugin({ require: ['kibana', 'elasticsearch'], uiExports: { @@ -18,7 +7,7 @@ export default function (kibana) { order: -1000, description: 'Time series expressions for everything', icon: 'plugins/timelion/icon.svg', - main: mainFile, + main: 'plugins/timelion/app', injectVars: function (server) { const config = server.config(); return { diff --git a/src/core_plugins/timelion/public/app.js b/src/core_plugins/timelion/public/app.js index d5aa6d5b8d49f..105f3f530afab 100644 --- a/src/core_plugins/timelion/public/app.js +++ b/src/core_plugins/timelion/public/app.js @@ -6,6 +6,7 @@ import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_regis import { notify } from 'ui/notify'; import { timezoneProvider } from 'ui/vis/lib/timezone'; +require('ui/autoload/all'); require('plugins/timelion/directives/cells/cells'); require('plugins/timelion/directives/fixed_element'); require('plugins/timelion/directives/fullscreen/fullscreen'); diff --git a/src/core_plugins/timelion/public/app_with_autoload.js b/src/core_plugins/timelion/public/app_with_autoload.js deleted file mode 100644 index 66d538a260174..0000000000000 --- a/src/core_plugins/timelion/public/app_with_autoload.js +++ /dev/null @@ -1,2 +0,0 @@ -require('ui/autoload/all'); -require('./app'); diff --git a/src/optimize/base_optimizer.js b/src/optimize/base_optimizer.js index bc66a8721ccb9..4a45291df1f4d 100644 --- a/src/optimize/base_optimizer.js +++ b/src/optimize/base_optimizer.js @@ -25,8 +25,7 @@ const BABEL_EXCLUDE_RE = [ export default class BaseOptimizer { constructor(opts) { - this.env = opts.env; - this.bundles = opts.bundles; + this.uiBundles = opts.uiBundles; this.profile = opts.profile || false; switch (opts.sourceMaps) { @@ -60,7 +59,7 @@ export default class BaseOptimizer { this.compiler.plugin('done', stats => { if (!this.profile) return; - const path = resolve(this.env.workingDir, 'stats.json'); + const path = this.uiBundles.resolvePath('stats.json'); const content = JSON.stringify(stats.toJson()); writeFile(path, content, function (err) { if (err) throw err; @@ -71,7 +70,7 @@ export default class BaseOptimizer { } getConfig() { - const cacheDirectory = resolve(this.env.workingDir, '../.cache', this.bundles.hashBundleEntries()); + const cacheDirectory = this.uiBundles.getCachePath(); function getStyleLoaders(preProcessors = [], postProcessors = []) { return ExtractTextPlugin.extract({ @@ -105,13 +104,13 @@ export default class BaseOptimizer { const commonConfig = { node: { fs: 'empty' }, context: fromRoot('.'), - entry: this.bundles.toWebpackEntries(), + entry: this.uiBundles.toWebpackEntries(), devtool: this.sourceMaps, profile: this.profile || false, output: { - path: this.env.workingDir, + path: this.uiBundles.getWorkingDir(), filename: '[name].bundle.js', sourceMapFilename: '[file].map', publicPath: PUBLIC_PATH_PLACEHOLDER, @@ -168,7 +167,7 @@ export default class BaseOptimizer { }, { test: /\.js$/, - exclude: BABEL_EXCLUDE_RE.concat(this.env.noParse), + exclude: BABEL_EXCLUDE_RE.concat(this.uiBundles.getWebpackNoParseRules()), use: [ { loader: 'cache-loader', @@ -187,12 +186,12 @@ export default class BaseOptimizer { }, ], }, - ...this.env.postLoaders.map(loader => ({ + ...this.uiBundles.getPostLoaders().map(loader => ({ enforce: 'post', ...loader })), ], - noParse: this.env.noParse, + noParse: this.uiBundles.getWebpackNoParseRules(), }, resolve: { @@ -205,12 +204,12 @@ export default class BaseOptimizer { 'node_modules', fromRoot('node_modules'), ], - alias: this.env.aliases, + alias: this.uiBundles.getAliases(), unsafeCache: this.unsafeCache, }, }; - if (this.env.context.env === 'development') { + if (this.uiBundles.isDevMode()) { return webpackMerge(commonConfig, { // In the test env we need to add react-addons (and a few other bits) for the // enzyme tests to work. diff --git a/src/optimize/bundles_route/__tests__/bundles_route.js b/src/optimize/bundles_route/__tests__/bundles_route.js index 623c7a7f0d74a..a38f5a28a1789 100644 --- a/src/optimize/bundles_route/__tests__/bundles_route.js +++ b/src/optimize/bundles_route/__tests__/bundles_route.js @@ -219,7 +219,7 @@ describe('optimizer/bundle route', () => { const server = createServer(); const response = await server.inject({ - url: '/bundles/non_existant.js' + url: '/bundles/non_existent.js' }); expect(response.statusCode).to.be(404); diff --git a/src/optimize/index.js b/src/optimize/index.js index 50caaadc7bf70..dde11b0ef3060 100644 --- a/src/optimize/index.js +++ b/src/optimize/index.js @@ -17,19 +17,21 @@ export default async (kbnServer, server, config) => { return await kbnServer.mixin(require('./lazy/lazy')); } - const bundles = kbnServer.bundles; + const { uiBundles } = kbnServer; server.route(createBundlesRoute({ - bundlesPath: bundles.env.workingDir, + bundlesPath: uiBundles.getWorkingDir(), basePublicPath: config.get('server.basePath') })); - await bundles.writeEntryFiles(); + await uiBundles.writeEntryFiles(); // in prod, only bundle when someing is missing or invalid - const invalidBundles = config.get('optimize.useBundleCache') ? await bundles.getInvalidBundles() : bundles; + const reuseCache = config.get('optimize.useBundleCache') + ? await uiBundles.areAllBundleCachesValid() + : false; // we might not have any work to do - if (!invalidBundles.getIds().length) { + if (reuseCache) { server.log( ['debug', 'optimize'], `All bundles are cached and ready to go!` @@ -39,8 +41,7 @@ export default async (kbnServer, server, config) => { // only require the FsOptimizer when we need to const optimizer = new FsOptimizer({ - env: bundles.env, - bundles: bundles, + uiBundles, profile: config.get('optimize.profile'), sourceMaps: config.get('optimize.sourceMaps'), unsafeCache: config.get('optimize.unsafeCache'), @@ -48,12 +49,12 @@ export default async (kbnServer, server, config) => { server.log( ['info', 'optimize'], - `Optimizing and caching ${bundles.desc()}. This may take a few minutes` + `Optimizing and caching ${uiBundles.getDescription()}. This may take a few minutes` ); const start = Date.now(); await optimizer.run(); const seconds = ((Date.now() - start) / 1000).toFixed(2); - server.log(['info', 'optimize'], `Optimization of ${bundles.desc()} complete in ${seconds} seconds`); + server.log(['info', 'optimize'], `Optimization of ${uiBundles.getDescription()} complete in ${seconds} seconds`); }; diff --git a/src/optimize/lazy/lazy_optimizer.js b/src/optimize/lazy/lazy_optimizer.js index 36db7adc2c50e..82d4b88fb1436 100644 --- a/src/optimize/lazy/lazy_optimizer.js +++ b/src/optimize/lazy/lazy_optimizer.js @@ -23,7 +23,7 @@ export default class LazyOptimizer extends BaseOptimizer { async init() { this.initializing = true; - await this.bundles.writeEntryFiles(); + await this.uiBundles.writeEntryFiles(); await this.initCompiler(); this.compiler.plugin('watch-run', (w, webpackCb) => { @@ -59,8 +59,8 @@ export default class LazyOptimizer extends BaseOptimizer { this.initializing = false; this.log(['info', 'optimize'], { - tmpl: `Lazy optimization of ${this.bundles.desc()} ready`, - bundles: this.bundles.getIds() + tmpl: `Lazy optimization of ${this.uiBundles.getDescription()} ready`, + bundles: this.uiBundles.getIds() }); } @@ -90,14 +90,14 @@ export default class LazyOptimizer extends BaseOptimizer { logRunStart() { this.log(['info', 'optimize'], { tmpl: `Lazy optimization started`, - bundles: this.bundles.getIds() + bundles: this.uiBundles.getIds() }); } logRunSuccess() { this.log(['info', 'optimize'], { tmpl: 'Lazy optimization <%= status %> in <%= seconds %> seconds', - bundles: this.bundles.getIds(), + bundles: this.uiBundles.getIds(), status: 'success', seconds: this.timer.end() }); @@ -110,7 +110,7 @@ export default class LazyOptimizer extends BaseOptimizer { this.log(['fatal', 'optimize'], { tmpl: 'Lazy optimization <%= status %> in <%= seconds %> seconds<%= err %>', - bundles: this.bundles.getIds(), + bundles: this.uiBundles.getIds(), status: 'failed', seconds: this.timer.end(), err: err diff --git a/src/optimize/lazy/optmzr_role.js b/src/optimize/lazy/optmzr_role.js index 284b0cc32317d..ba50258078e76 100644 --- a/src/optimize/lazy/optmzr_role.js +++ b/src/optimize/lazy/optmzr_role.js @@ -8,8 +8,7 @@ export default async (kbnServer, kibanaHapiServer, config) => { config.get('server.basePath'), new LazyOptimizer({ log: (tags, data) => kibanaHapiServer.log(tags, data), - env: kbnServer.bundles.env, - bundles: kbnServer.bundles, + uiBundles: kbnServer.uiBundles, profile: config.get('optimize.profile'), sourceMaps: config.get('optimize.sourceMaps'), prebuild: config.get('optimize.lazyPrebuild'), diff --git a/src/plugin_discovery/README.md b/src/plugin_discovery/README.md new file mode 100644 index 0000000000000..90d166b3ea944 --- /dev/null +++ b/src/plugin_discovery/README.md @@ -0,0 +1,140 @@ +# Plugin Discovery + +The plugin discovery module defines the core plugin loading logic used by the Kibana server. It exports functions for + + +## `findPluginSpecs(settings, [config])` + +Finds [`PluginSpec`][PluginSpec] objects + +### params + - `settings`: the same settings object accepted by [`KbnServer`][KbnServer] + - `[config]`: Optional - a [`Config`][Config] service. Using this param causes `findPluginSpecs()` to modify `config`'s schema to support the configuration for each discovered [`PluginSpec`][PluginSpec]. If you can, please use the [`Config`][Config] service produced by `extendedConfig$` rather than passing in an existing service so that `findPluginSpecs()` is side-effect free. + +### return value + +`findPluginSpecs()` returns an object of Observables which produce values at different parts of the process. Since the Observables are all aware of their own dependencies you can subscribe to any combination (within the same tick) and only the necessary plugin logic will be executed. + +If you *never* subscribe to any of the Observables then plugin discovery won't actually run. + + - `pack$`: emits every [`PluginPack`][PluginPack] found + - `invalidDirectoryError$: Observable`: emits [`InvalidDirectoryError`][Errors]s caused by `settings.plugins.scanDirs` values that don't point to actual directories. `findPluginSpecs()` will not abort when this error is encountered. + - `invalidPackError$: Observable`: emits [`InvalidPackError`][Errors]s caused by children of `settings.plugins.scanDirs` or `settings.plugins.paths` values which don't meet the requirements of a [`PluginPack`][PluginPack] (probably missing a `package.json`). `findPluginSpecs()` will not abort when this error is encountered. + - `deprecation$: Observable`: emits deprecation warnings that are produces when reading each [`PluginPack`][PluginPack]'s configuration + - `extendedConfig$: Observable`: emits the [`Config`][Config] service that was passed to `findPluginSpecs()` (or created internally if none was passed) after it has been extended with the configuration from each plugin + - `spec$: Observable`: emits every *enabled* [`PluginSpec`][PluginSpec] defined by the discovered [`PluginPack`][PluginPack]s + - `disabledSpecs$: Observable`: emits every *disabled* [`PluginSpec`][PluginSpec] defined by the discovered [`PluginPack`][PluginPack]s + - `invalidVersionSpec$: Observable`: emits every [`PluginSpec`][PluginSpec] who's required kibana version does not match the version exposed by `config.get('pkg.version')` + +### example + +Just get the plugin specs, only fail if there is an uncaught error of some sort: +```js +const { pack$ } = findPluginSpecs(settings); +const packs = await pack$.toArray().toPromise() +``` + +Just log the deprecation messages: +```js +const { deprecation$ } = findPluginSpecs(settings); +for (const warning of await deprecation$.toArray().toPromise()) { + console.log('DEPRECATION:', warning) +} +``` + +Get the packs but fail if any packs are invalid: +```js +const { pack$, invalidDirectoryError$ } = findPluginSpecs(settings); +const packs = await Observable.merge( + pack$.toArray(), + + // if we ever get an InvalidDirectoryError, throw it + // into the stream so that all streams are unsubscribed, + // the discovery process is aborted, and the promise rejects + invalidDirectoryError$.map(error => { + throw error + }), +).toPromise() +``` + +Handle everything +```js +const { + pack$, + invalidDirectoryError$, + invalidPackError$, + deprecation$, + extendedConfig$, + spec$, + disabledSpecs$, + invalidVersionSpec$, +} = findPluginSpecs(settings); + +Observable.merge( + pack$ + .do(pluginPack => console.log('Found plugin pack', pluginPack)), + + invalidDirectoryError$ + .do(error => console.log('Invalid directory error', error)), + + invalidPackError$ + .do(error => console.log('Invalid plugin pack error', error)), + + deprecation$ + .do(msg => console.log('DEPRECATION:', msg)), + + extendedConfig$ + .do(config => console.log('config service extended by plugins', config)), + + spec$ + .do(pluginSpec => console.log('enabled plugin spec found', spec)), + + disabledSpec$ + .do(pluginSpec => console.log('disabled plugin spec found', spec)), + + invalidVersionSpec$ + .do(pluginSpec => console.log('plugin spec with invalid version found', spec)), +) +.toPromise() +.then(() => { + console.log('plugin discovery complete') +}) +.catch((error) => { + console.log('plugin discovery failed', error) +}) + +``` + +## `reduceExportSpecs(pluginSpecs, reducers, [defaults={}])` + +Reduces every value exported by the [`PluginSpec`][PluginSpec]s to produce a single value. If an exported value is an array each item in the array will be reduced individually. If the exported value is `undefined` it will be ignored. The reducer is called with the signature: + +```js +reducer( + // the result of the previous reducer call, or `defaults` + acc: any, + // the exported value, found at `uiExports[type]` or `uiExports[type][i]` + // in the PluginSpec config. + spec: any, + // the key in `uiExports` where this export was found + type: string, + // the PluginSpec which exported this spec + pluginSpec: PluginSpec +) +``` + +## `new PluginPack(options)` class + +Only exported so that `PluginPack` instances can be created in tests and used in place of on-disk plugin fixtures. Use `findPluginSpecs()`, or the cached result of a call to `findPluginSpecs()` (like `kbnServer.pluginSpecs`) any time you might need access to `PluginPack` objects in distributed code. + +### params + + - `options.path`: absolute path to where this plugin pack was found, this is normally a direct child of `./src/core_plugins` or `./plugins` + - `options.pkg`: the parsed `package.json` for this pack, used for defaults in `PluginSpec` objects defined by this pack + - `options.provider`: the default export of the pack, a function which is called with the `PluginSpec` class which should return one or more `PluginSpec` objects. + +[PluginPack]: ./plugin_pack/plugin_pack.js "PluginPath class definition" +[PluginSpec]: ./plugin_spec/plugin_spec.js "PluginSpec class definition" +[Errors]: ./errors.js "PluginDiscover specific error types" +[KbnServer]: ../server/kbn_server.js "KbnServer class definition" +[Config]: ../server/config/config.js "KbnServer/Config class definition" \ No newline at end of file diff --git a/src/plugin_discovery/__tests__/find_plugin_specs.js b/src/plugin_discovery/__tests__/find_plugin_specs.js new file mode 100644 index 0000000000000..b0361b8d0e877 --- /dev/null +++ b/src/plugin_discovery/__tests__/find_plugin_specs.js @@ -0,0 +1,74 @@ +import { resolve } from 'path'; + +import expect from 'expect.js'; +import { findPluginSpecs } from '../find_plugin_specs'; +import { PluginSpec } from '../plugin_spec'; + +const PLUGIN_FIXTURES = resolve(__dirname, 'fixtures/plugins'); + +describe('plugin discovery', () => { + describe('findPluginSpecs()', function () { + this.timeout(10000); + + it('finds specs for specified plugin paths', async () => { + const { spec$ } = findPluginSpecs({ + plugins: { + paths: [ + resolve(PLUGIN_FIXTURES, 'foo'), + resolve(PLUGIN_FIXTURES, 'bar'), + ] + } + }); + + const specs = await spec$.toArray().toPromise(); + expect(specs).to.have.length(3); + specs.forEach(spec => { + expect(spec).to.be.a(PluginSpec); + }); + expect(specs.map(s => s.getId()).sort()) + .to.eql(['bar:one', 'bar:two', 'foo']); + }); + + it('finds all specs in scanDirs', async () => { + const { spec$ } = findPluginSpecs({ + // used to ensure the dev_mode plugin is enabled + env: 'development', + + plugins: { + scanDirs: [PLUGIN_FIXTURES] + } + }); + + const specs = await spec$.toArray().toPromise(); + expect(specs).to.have.length(3); + specs.forEach(spec => { + expect(spec).to.be.a(PluginSpec); + }); + expect(specs.map(s => s.getId()).sort()) + .to.eql(['bar:one', 'bar:two', 'foo']); + }); + + it('does not find disabled plugins', async () => { + const { spec$ } = findPluginSpecs({ + 'bar:one': { + enabled: false + }, + + plugins: { + paths: [ + resolve(PLUGIN_FIXTURES, 'foo'), + resolve(PLUGIN_FIXTURES, 'bar') + ] + } + }); + + const specs = await spec$.toArray().toPromise(); + expect(specs).to.have.length(2); + specs.forEach(spec => { + expect(spec).to.be.a(PluginSpec); + }); + expect(specs.map(s => s.getId()).sort()) + .to.eql(['bar:two', 'foo']); + }); + }); +}); diff --git a/src/plugin_discovery/__tests__/fixtures/plugins/bar/index.js b/src/plugin_discovery/__tests__/fixtures/plugins/bar/index.js new file mode 100644 index 0000000000000..32aea33e36c33 --- /dev/null +++ b/src/plugin_discovery/__tests__/fixtures/plugins/bar/index.js @@ -0,0 +1,10 @@ +export default function (kibana) { + return [ + new kibana.Plugin({ + id: 'bar:one', + }), + new kibana.Plugin({ + id: 'bar:two', + }), + ]; +} diff --git a/src/plugin_discovery/__tests__/fixtures/plugins/bar/package.json b/src/plugin_discovery/__tests__/fixtures/plugins/bar/package.json new file mode 100644 index 0000000000000..e43c2f0bc984c --- /dev/null +++ b/src/plugin_discovery/__tests__/fixtures/plugins/bar/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "version": "kibana" +} diff --git a/src/plugin_discovery/__tests__/fixtures/plugins/foo/index.js b/src/plugin_discovery/__tests__/fixtures/plugins/foo/index.js new file mode 100644 index 0000000000000..cbb05e0deed52 --- /dev/null +++ b/src/plugin_discovery/__tests__/fixtures/plugins/foo/index.js @@ -0,0 +1,5 @@ +module.exports = function (kibana) { + return new kibana.Plugin({ + id: 'foo', + }); +}; diff --git a/src/plugin_discovery/__tests__/fixtures/plugins/foo/package.json b/src/plugin_discovery/__tests__/fixtures/plugins/foo/package.json new file mode 100644 index 0000000000000..e43c2f0bc984c --- /dev/null +++ b/src/plugin_discovery/__tests__/fixtures/plugins/foo/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "version": "kibana" +} diff --git a/src/plugin_discovery/errors.js b/src/plugin_discovery/errors.js new file mode 100644 index 0000000000000..d96014812bc88 --- /dev/null +++ b/src/plugin_discovery/errors.js @@ -0,0 +1,63 @@ + +const errorCodeProperty = Symbol('pluginDiscovery/errorCode'); + +/** + * Thrown when reading a plugin directory fails, wraps failure + * @type {String} + */ +const ERROR_INVALID_DIRECTORY = 'ERROR_INVALID_DIRECTORY'; +export function createInvalidDirectoryError(sourceError, path) { + sourceError[errorCodeProperty] = ERROR_INVALID_DIRECTORY; + sourceError.path = path; + return sourceError; +} +export function isInvalidDirectoryError(error) { + return error && error[errorCodeProperty] === ERROR_INVALID_DIRECTORY; +} + + +/** + * Thrown when trying to create a PluginPack for a path that + * is not a valid plugin definition + * @type {String} + */ +const ERROR_INVALID_PACK = 'ERROR_INVALID_PACK'; +export function createInvalidPackError(path, reason) { + const error = new Error(`PluginPack${path ? ` at "${path}"` : ''} ${reason}`); + error[errorCodeProperty] = ERROR_INVALID_PACK; + error.path = path; + return error; +} +export function isInvalidPackError(error) { + return error && error[errorCodeProperty] === ERROR_INVALID_PACK; +} + +/** + * Thrown when trying to load a PluginSpec that is invalid for some reason + * @type {String} + */ +const ERROR_INVALID_PLUGIN = 'ERROR_INVALID_PLUGIN'; +export function createInvalidPluginError(spec, reason) { + const error = new Error(`Plugin from ${spec.getId()} at ${spec.getPack().getPath()} is invalid because ${reason}`); + error[errorCodeProperty] = ERROR_INVALID_PLUGIN; + error.spec = spec; + return error; +} +export function isInvalidPluginError(error) { + return error && error[errorCodeProperty] === ERROR_INVALID_PLUGIN; +} + +/** + * Thrown when trying to load a PluginSpec whose version is incompatible + * @type {String} + */ +const ERROR_INCOMPATIBLE_PLUGIN_VERSION = 'ERROR_INCOMPATIBLE_PLUGIN_VERSION'; +export function createIncompatiblePluginVersionError(spec) { + const error = new Error(`Plugin ${spec.getId()} is only compatible with Kibana version ${spec.getExpectedKibanaVersion()}`); + error[errorCodeProperty] = ERROR_INCOMPATIBLE_PLUGIN_VERSION; + error.spec = spec; + return error; +} +export function isIncompatiblePluginVersionError(error) { + return error && error[errorCodeProperty] === ERROR_INCOMPATIBLE_PLUGIN_VERSION; +} diff --git a/src/plugin_discovery/find_plugin_specs.js b/src/plugin_discovery/find_plugin_specs.js new file mode 100644 index 0000000000000..8eb77c7d35969 --- /dev/null +++ b/src/plugin_discovery/find_plugin_specs.js @@ -0,0 +1,134 @@ +import { Observable } from 'rxjs'; + +import { transformDeprecations, Config } from '../server/config'; + +import { + extendConfigService, + disableConfigExtension, +} from './plugin_config'; + +import { + createPackAtPath$, + createPacksInDirectory$, +} from './plugin_pack'; + +import { + isInvalidDirectoryError, + isInvalidPackError, +} from './errors'; + +function defaultConfig(settings) { + return Config.withDefaultSchema( + transformDeprecations(settings) + ); +} + +function bufferAllResults(observable) { + return observable + // buffer all results into a single array + .toArray() + // merge the array back into the stream when complete + .mergeMap(array => array); +} + +/** + * Creates a collection of observables for discovering pluginSpecs + * using Kibana's defaults, settings, and config service + * + * @param {Object} settings + * @param {ConfigService} [config] when supplied **it is mutated** to include + * the config from discovered plugin specs + * @return {Object} + */ +export function findPluginSpecs(settings, config = defaultConfig(settings)) { + // find plugin packs in configured paths/dirs + const find$ = Observable.merge( + ...config.get('plugins.paths').map(createPackAtPath$), + ...config.get('plugins.scanDirs').map(createPacksInDirectory$) + ) + .share(); + + const extendConfig$ = find$ + // get the specs for each found plugin pack + .mergeMap(({ pack }) => ( + pack ? pack.getPluginSpecs() : [] + )) + .mergeMap(async (spec) => { + // extend the config service with this plugin spec and + // collect its deprecations messages if some of its + // settings are outdated + const deprecations = []; + await extendConfigService(spec, config, settings, (message) => { + deprecations.push({ spec, message }); + }); + + return { + spec, + deprecations, + }; + }) + // extend the config with all plugins before determining enabled status + .let(bufferAllResults) + .map(({ spec, deprecations }) => { + const isRightVersion = spec.isVersionCompatible(config.get('pkg.version')); + const enabled = isRightVersion && spec.isEnabled(config); + return { + spec, + deprecations, + enabledSpecs: enabled ? [spec] : [], + disabledSpecs: enabled ? [] : [spec], + invalidVersionSpecs: isRightVersion ? [] : [spec], + }; + }) + // determine which plugins are disabled before actually removing things from the config + .let(bufferAllResults) + .do(result => { + for (const spec of result.disabledSpecs) { + disableConfigExtension(spec, config); + } + }) + .share(); + + return { + // plugin packs found when searching configured paths + pack$: find$ + .mergeMap(result => ( + result.pack ? [result.pack] : [] + )), + + // errors caused by invalid directories of plugin directories + invalidDirectoryError$: find$ + .mergeMap(result => ( + isInvalidDirectoryError(result.error) ? [result.error] : [] + )), + + // errors caused by directories that we expected to be plugin but were invalid + invalidPackError$: find$ + .mergeMap(result => ( + isInvalidPackError(result.error) ? [result.error] : [] + )), + + // { spec, message } objects produced when transforming deprecated + // settings for a plugin spec + deprecation$: extendConfig$ + .mergeMap(result => result.deprecations), + + // the config service we extended with all of the plugin specs, + // only emitted once it is fully extended by all + extendedConfig$: extendConfig$ + .ignoreElements() + .concat([config]), + + // all enabled PluginSpec objects + spec$: extendConfig$ + .mergeMap(result => result.enabledSpecs), + + // all disabled PluginSpec objects + disabledSpecs$: extendConfig$ + .mergeMap(result => result.disabledSpecs), + + // all PluginSpec objects that were disabled because their version was incompatible + invalidVersionSpec$: extendConfig$ + .mergeMap(result => result.invalidVersionSpecs), + }; +} diff --git a/src/plugin_discovery/index.js b/src/plugin_discovery/index.js new file mode 100644 index 0000000000000..bfee8021766a6 --- /dev/null +++ b/src/plugin_discovery/index.js @@ -0,0 +1,3 @@ +export { findPluginSpecs } from './find_plugin_specs'; +export { reduceExportSpecs } from './plugin_exports'; +export { PluginPack } from './plugin_pack'; diff --git a/src/plugin_discovery/plugin_config/__tests__/extend_config_service.js b/src/plugin_discovery/plugin_config/__tests__/extend_config_service.js new file mode 100644 index 0000000000000..ca1f9adbd3104 --- /dev/null +++ b/src/plugin_discovery/plugin_config/__tests__/extend_config_service.js @@ -0,0 +1,177 @@ +import sinon from 'sinon'; +import expect from 'expect.js'; + +import { Config } from '../../../server/config'; +import { PluginPack } from '../../plugin_pack'; +import { extendConfigService, disableConfigExtension } from '../extend_config_service'; +import * as SchemaNS from '../schema'; +import * as SettingsNS from '../settings'; + +describe('plugin discovery/extend config service', () => { + const sandbox = sinon.sandbox.create(); + afterEach(() => sandbox.restore()); + + const pluginSpec = new PluginPack({ + path: '/dev/null', + pkg: { + name: 'test', + version: 'kibana', + }, + provider: ({ Plugin }) => new Plugin({ + configPrefix: 'foo.bar.baz', + + config: Joi => Joi.object({ + enabled: Joi.boolean().default(true), + test: Joi.string().default('bonk'), + }).default(), + + deprecations({ rename }) { + return [ + rename('oldTest', 'test'), + ]; + }, + }), + }) + .getPluginSpecs() + .pop(); + + describe('extendConfigService()', () => { + it('calls getSettings, getSchema, and Config.extendSchema() correctly', async () => { + const rootSettings = { + foo: { + bar: { + enabled: false + } + } + }; + const schema = { + validate: () => {} + }; + const configPrefix = 'foo.bar'; + const config = { + extendSchema: sandbox.stub() + }; + const pluginSpec = { + getConfigPrefix: sandbox.stub() + .returns(configPrefix) + }; + + const logDeprecation = sandbox.stub(); + + const getSettings = sandbox.stub(SettingsNS, 'getSettings') + .returns(rootSettings.foo.bar); + + const getSchema = sandbox.stub(SchemaNS, 'getSchema') + .returns(schema); + + await extendConfigService(pluginSpec, config, rootSettings, logDeprecation); + + sinon.assert.calledOnce(getSettings); + sinon.assert.calledWithExactly(getSettings, pluginSpec, rootSettings, logDeprecation); + + sinon.assert.calledOnce(getSchema); + sinon.assert.calledWithExactly(getSchema, pluginSpec); + + sinon.assert.calledOnce(config.extendSchema); + sinon.assert.calledWithExactly(config.extendSchema, schema, rootSettings.foo.bar, configPrefix); + }); + + it('adds the schema for a plugin spec to its config prefix', async () => { + const config = Config.withDefaultSchema(); + expect(config.has('foo.bar.baz')).to.be(false); + await extendConfigService(pluginSpec, config); + expect(config.has('foo.bar.baz')).to.be(true); + }); + + it('initializes it with the default settings', async () => { + const config = Config.withDefaultSchema(); + await extendConfigService(pluginSpec, config); + expect(config.get('foo.bar.baz.enabled')).to.be(true); + expect(config.get('foo.bar.baz.test')).to.be('bonk'); + }); + + it('initializes it with values from root settings if defined', async () => { + const config = Config.withDefaultSchema(); + await extendConfigService(pluginSpec, config, { + foo: { + bar: { + baz: { + test: 'hello world' + } + } + } + }); + + expect(config.get('foo.bar.baz.test')).to.be('hello world'); + }); + + it('throws if root settings are invalid', async () => { + const config = Config.withDefaultSchema(); + try { + await extendConfigService(pluginSpec, config, { + foo: { + bar: { + baz: { + test: { + 'not a string': true + } + } + } + } + }); + throw new Error('Expected extendConfigService() to throw because of bad settings'); + } catch (error) { + expect(error.message).to.contain('"test" must be a string'); + } + }); + + it('calls logDeprecation() with deprecation messages', async () => { + const config = Config.withDefaultSchema(); + const logDeprecation = sinon.stub(); + await extendConfigService(pluginSpec, config, { + foo: { + bar: { + baz: { + oldTest: '123' + } + } + } + }, logDeprecation); + sinon.assert.calledOnce(logDeprecation); + sinon.assert.calledWithExactly(logDeprecation, sinon.match('"oldTest" is deprecated')); + }); + + it('uses settings after transforming deprecations', async () => { + const config = Config.withDefaultSchema(); + await extendConfigService(pluginSpec, config, { + foo: { + bar: { + baz: { + oldTest: '123' + } + } + } + }); + expect(config.get('foo.bar.baz.test')).to.be('123'); + }); + }); + + describe('disableConfigExtension()', () => { + it('removes added config', async () => { + const config = Config.withDefaultSchema(); + await extendConfigService(pluginSpec, config); + expect(config.has('foo.bar.baz.test')).to.be(true); + await disableConfigExtension(pluginSpec, config); + expect(config.has('foo.bar.baz.test')).to.be(false); + }); + + it('leaves {configPrefix}.enabled config', async () => { + const config = Config.withDefaultSchema(); + expect(config.has('foo.bar.baz.enabled')).to.be(false); + await extendConfigService(pluginSpec, config); + expect(config.get('foo.bar.baz.enabled')).to.be(true); + await disableConfigExtension(pluginSpec, config); + expect(config.get('foo.bar.baz.enabled')).to.be(false); + }); + }); +}); diff --git a/src/plugin_discovery/plugin_config/__tests__/schema.js b/src/plugin_discovery/plugin_config/__tests__/schema.js new file mode 100644 index 0000000000000..a04e3304dd758 --- /dev/null +++ b/src/plugin_discovery/plugin_config/__tests__/schema.js @@ -0,0 +1,72 @@ +import expect from 'expect.js'; + +import { PluginPack } from '../../plugin_pack'; +import { getSchema, getStubSchema } from '../schema'; + +describe('plugin discovery/schema', () => { + function createPluginSpec(configProvider) { + return new PluginPack({ + path: '/dev/null', + pkg: { + name: 'test', + version: 'kibana', + }, + provider: ({ Plugin }) => new Plugin({ + configPrefix: 'foo.bar.baz', + config: configProvider, + }), + }) + .getPluginSpecs() + .pop(); + } + + describe('getSchema()', () => { + it('calls the config provider and returns its return value', async () => { + const pluginSpec = createPluginSpec(() => 'foo'); + expect(await getSchema(pluginSpec)).to.be('foo'); + }); + + it('supports config provider that returns a promise', async () => { + const pluginSpec = createPluginSpec(() => Promise.resolve('foo')); + expect(await getSchema(pluginSpec)).to.be('foo'); + }); + + it('uses default schema when no config provider', async () => { + const schema = await getSchema(createPluginSpec()); + expect(schema).to.be.an('object'); + expect(schema).to.have.property('validate').a('function'); + expect(schema.validate({}).value).to.eql({ + enabled: true + }); + }); + + it('uses default schema when config returns falsy value', async () => { + const schema = await getSchema(createPluginSpec(() => null)); + expect(schema).to.be.an('object'); + expect(schema).to.have.property('validate').a('function'); + expect(schema.validate({}).value).to.eql({ + enabled: true + }); + }); + + it('uses default schema when config promise resolves to falsy value', async () => { + const schema = await getSchema(createPluginSpec(() => Promise.resolve(null))); + expect(schema).to.be.an('object'); + expect(schema).to.have.property('validate').a('function'); + expect(schema.validate({}).value).to.eql({ + enabled: true + }); + }); + }); + + describe('getStubSchema()', () => { + it('returns schema with enabled: false', async () => { + const schema = await getStubSchema(); + expect(schema).to.be.an('object'); + expect(schema).to.have.property('validate').a('function'); + expect(schema.validate({}).value).to.eql({ + enabled: false + }); + }); + }); +}); diff --git a/src/plugin_discovery/plugin_config/__tests__/settings.js b/src/plugin_discovery/plugin_config/__tests__/settings.js new file mode 100644 index 0000000000000..8883cbf95a057 --- /dev/null +++ b/src/plugin_discovery/plugin_config/__tests__/settings.js @@ -0,0 +1,64 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; + +import { PluginPack } from '../../plugin_pack'; +import { getSettings } from '../settings'; + +describe('plugin_discovery/settings', () => { + const pluginSpec = new PluginPack({ + path: '/dev/null', + pkg: { + name: 'test', + version: 'kibana', + }, + provider: ({ Plugin }) => new Plugin({ + configPrefix: 'a.b.c', + deprecations: ({ rename }) => [ + rename('foo', 'bar') + ] + }), + }) + .getPluginSpecs() + .pop(); + + describe('getSettings()', () => { + it('reads settings from config prefix', async () => { + const rootSettings = { + a: { + b: { + c: { + enabled: false + } + } + } + }; + + expect(await getSettings(pluginSpec, rootSettings)) + .to.eql({ + enabled: false + }); + }); + + it('allows rootSettings to be undefined', async () => { + expect(await getSettings(pluginSpec)) + .to.eql(undefined); + }); + + it('resolves deprecations', async () => { + const logDeprecation = sinon.stub(); + expect(await getSettings(pluginSpec, { + a: { + b: { + c: { + foo: true + } + } + } + }, logDeprecation)).to.eql({ + bar: true + }); + + sinon.assert.calledOnce(logDeprecation); + }); + }); +}); diff --git a/src/plugin_discovery/plugin_config/extend_config_service.js b/src/plugin_discovery/plugin_config/extend_config_service.js new file mode 100644 index 0000000000000..fdd33993da1ba --- /dev/null +++ b/src/plugin_discovery/plugin_config/extend_config_service.js @@ -0,0 +1,31 @@ +import { getSettings } from './settings'; +import { getSchema, getStubSchema } from './schema'; + +/** + * Extend a config service with the schema and settings for a + * plugin spec and optionally call logDeprecation with warning + * messages about deprecated settings that are used + * @param {PluginSpec} spec + * @param {Server.Config} config + * @param {Object} rootSettings + * @param {Function} [logDeprecation] + * @return {Promise} + */ +export async function extendConfigService(spec, config, rootSettings, logDeprecation) { + const settings = await getSettings(spec, rootSettings, logDeprecation); + const schema = await getSchema(spec); + config.extendSchema(schema, settings, spec.getConfigPrefix()); +} + +/** + * Disable the schema and settings applied to a config service for + * a plugin spec + * @param {PluginSpec} spec + * @param {Server.Config} config + * @return {undefined} + */ +export function disableConfigExtension(spec, config) { + const prefix = spec.getConfigPrefix(); + config.removeSchema(prefix); + config.extendSchema(getStubSchema(), { enabled: false }, prefix); +} diff --git a/src/plugin_discovery/plugin_config/index.js b/src/plugin_discovery/plugin_config/index.js new file mode 100644 index 0000000000000..3d1a084b99fe5 --- /dev/null +++ b/src/plugin_discovery/plugin_config/index.js @@ -0,0 +1,4 @@ +export { + extendConfigService, + disableConfigExtension, +} from './extend_config_service'; diff --git a/src/plugin_discovery/plugin_config/schema.js b/src/plugin_discovery/plugin_config/schema.js new file mode 100644 index 0000000000000..5bddb6bd39ecb --- /dev/null +++ b/src/plugin_discovery/plugin_config/schema.js @@ -0,0 +1,24 @@ +import Joi from 'joi'; + +const STUB_CONFIG_SCHEMA = Joi.object().keys({ + enabled: Joi.valid(false).default(false) +}).default(); + +const DEFAULT_CONFIG_SCHEMA = Joi.object().keys({ + enabled: Joi.boolean().default(true) +}).default(); + + +/** + * Get the config schema for a plugin spec + * @param {PluginSpec} spec + * @return {Promise} + */ +export async function getSchema(spec) { + const provider = spec.getConfigSchemaProvider(); + return (provider && await provider(Joi)) || DEFAULT_CONFIG_SCHEMA; +} + +export function getStubSchema() { + return STUB_CONFIG_SCHEMA; +} diff --git a/src/plugin_discovery/plugin_config/settings.js b/src/plugin_discovery/plugin_config/settings.js new file mode 100644 index 0000000000000..58102353e3ea4 --- /dev/null +++ b/src/plugin_discovery/plugin_config/settings.js @@ -0,0 +1,25 @@ +import { get, noop } from 'lodash'; + +import * as serverConfig from '../../server/config'; +import { createTransform, Deprecations } from '../../deprecation'; + +async function getDeprecationTransformer(spec) { + const provider = spec.getDeprecationsProvider() || noop; + return createTransform(await provider(Deprecations) || []); +} + +/** + * Get the settings for a pluginSpec from the raw root settings while + * optionally calling logDeprecation() with warnings about deprecated + * settings that were used + * @param {PluginSpec} spec + * @param {Object} rootSettings + * @param {Function} [logDeprecation] + * @return {Promise} + */ +export async function getSettings(spec, rootSettings, logDeprecation) { + const prefix = spec.getConfigPrefix(); + const transformer = await getDeprecationTransformer(spec); + const rawSettings = get(serverConfig.transformDeprecations(rootSettings), prefix); + return transformer(rawSettings, logDeprecation); +} diff --git a/src/plugin_discovery/plugin_exports/__tests__/reduce_export_specs.js b/src/plugin_discovery/plugin_exports/__tests__/reduce_export_specs.js new file mode 100644 index 0000000000000..4b05b79ada9e1 --- /dev/null +++ b/src/plugin_discovery/plugin_exports/__tests__/reduce_export_specs.js @@ -0,0 +1,77 @@ +import expect from 'expect.js'; + +import { PluginPack } from '../../plugin_pack'; +import { reduceExportSpecs } from '../reduce_export_specs'; + +const PLUGIN = new PluginPack({ + path: __dirname, + pkg: { + name: 'foo', + version: 'kibana' + }, + provider: ({ Plugin }) => ( + new Plugin({ + uiExports: { + concatNames: { + name: 'export1' + }, + + concat: [ + 'export2', + 'export3', + ], + } + }) + ) +}); + +const REDUCERS = { + concatNames(acc, spec, type, pluginSpec) { + return { + names: [].concat( + acc.names || [], + `${pluginSpec.getId()}:${spec.name}`, + ) + }; + }, + concat(acc, spec, type, pluginSpec) { + return { + names: [].concat( + acc.names || [], + `${pluginSpec.getId()}:${spec}`, + ) + }; + }, +}; + +const PLUGIN_SPECS = PLUGIN.getPluginSpecs(); + +describe('reduceExportSpecs', () => { + it('combines ui exports from a list of plugin definitions', () => { + const exports = reduceExportSpecs(PLUGIN_SPECS, REDUCERS); + expect(exports).to.eql({ + names: [ + 'foo:export1', + 'foo:export2', + 'foo:export3', + ] + }); + }); + + it('starts with the defaults', () => { + const exports = reduceExportSpecs(PLUGIN_SPECS, REDUCERS, { + names: [ + 'default' + ] + }); + + expect(exports).to.eql({ + names: [ + 'default', + 'foo:export1', + 'foo:export2', + 'foo:export3', + ] + }); + }); +}); diff --git a/src/plugin_discovery/plugin_exports/index.js b/src/plugin_discovery/plugin_exports/index.js new file mode 100644 index 0000000000000..3e72163b30709 --- /dev/null +++ b/src/plugin_discovery/plugin_exports/index.js @@ -0,0 +1 @@ +export { reduceExportSpecs } from './reduce_export_specs'; diff --git a/src/plugin_discovery/plugin_exports/reduce_export_specs.js b/src/plugin_discovery/plugin_exports/reduce_export_specs.js new file mode 100644 index 0000000000000..cd3343db8c12a --- /dev/null +++ b/src/plugin_discovery/plugin_exports/reduce_export_specs.js @@ -0,0 +1,34 @@ +/** + * Combine the exportSpecs from a list of pluginSpecs + * by calling the reducers for each export type + * @param {Array} pluginSpecs + * @param {Object} reducers + * @param {Object} [defaults={}] + * @return {Object} + */ +export function reduceExportSpecs(pluginSpecs, reducers, defaults = {}) { + return pluginSpecs.reduce((acc, pluginSpec) => { + const specsByType = pluginSpec.getExportSpecs() || {}; + const types = Object.keys(specsByType); + + return types.reduce((acc, type) => { + const reducer = (reducers[type] || reducers.unknown); + + if (!reducer) { + throw new Error(`Unknown export type ${type}`); + } + + // convert specs to an array if not already one or + // ignore the spec if it is undefined + const specs = [].concat( + specsByType[type] === undefined + ? [] + : specsByType[type] + ); + + return specs.reduce((acc, spec) => ( + reducer(acc, spec, type, pluginSpec) + ), acc); + }, acc); + }, defaults); +} diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/broken/package.json b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/broken/package.json new file mode 100644 index 0000000000000..f830e8b60c02d --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/broken/package.json @@ -0,0 +1,3 @@ +{ + "name": +} diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_number/index.js b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_number/index.js new file mode 100644 index 0000000000000..aef22247d7526 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_number/index.js @@ -0,0 +1 @@ +export default 1; diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_number/package.json b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_number/package.json new file mode 100644 index 0000000000000..e43c2f0bc984c --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_number/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "version": "kibana" +} diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_object/index.js b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_object/index.js new file mode 100644 index 0000000000000..7cf8282359b41 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_object/index.js @@ -0,0 +1,3 @@ +export default { + foo: 'bar' +}; diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_object/package.json b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_object/package.json new file mode 100644 index 0000000000000..e43c2f0bc984c --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_object/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "version": "kibana" +} diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_string/index.js b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_string/index.js new file mode 100644 index 0000000000000..d02ba545bd3b3 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_string/index.js @@ -0,0 +1 @@ +export default 'foo'; diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_string/package.json b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_string/package.json new file mode 100644 index 0000000000000..e43c2f0bc984c --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/exports_string/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "version": "kibana" +} diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/foo/index.js b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/foo/index.js new file mode 100644 index 0000000000000..cbb05e0deed52 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/foo/index.js @@ -0,0 +1,5 @@ +module.exports = function (kibana) { + return new kibana.Plugin({ + id: 'foo', + }); +}; diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/foo/package.json b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/foo/package.json new file mode 100644 index 0000000000000..e43c2f0bc984c --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/foo/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "version": "kibana" +} diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/index.js b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/index.js new file mode 100644 index 0000000000000..6be02374db118 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/index.js @@ -0,0 +1 @@ +console.log('hello world'); diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/lib/index.js b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/lib/index.js new file mode 100644 index 0000000000000..32d8cd8593788 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/lib/index.js @@ -0,0 +1 @@ +export { myLib } from './my_lib'; diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/lib/my_lib.js b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/lib/my_lib.js new file mode 100644 index 0000000000000..db2df02b5b743 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/lib/my_lib.js @@ -0,0 +1,3 @@ +export function myLib() { + console.log('lib'); +} diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/prebuilt/index.js b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/prebuilt/index.js new file mode 100644 index 0000000000000..89744b2dd3fd9 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/prebuilt/index.js @@ -0,0 +1,14 @@ +/* eslint-disable */ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +exports.default = function (_ref) { + var Plugin = _ref.Plugin; + + return new Plugin({ + id: 'foo' + }); +}; diff --git a/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/prebuilt/package.json b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/prebuilt/package.json new file mode 100644 index 0000000000000..b1b74e0e76b12 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/prebuilt/package.json @@ -0,0 +1,3 @@ +{ + "name": "prebuilt" +} diff --git a/src/plugin_discovery/plugin_pack/__tests__/pack_at_path.js b/src/plugin_discovery/plugin_pack/__tests__/pack_at_path.js new file mode 100644 index 0000000000000..1f8e63fbd2e0d --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/pack_at_path.js @@ -0,0 +1,76 @@ +import { resolve } from 'path'; + +import expect from 'expect.js'; + +import { createPackAtPath$ } from '../pack_at_path'; +import { PluginPack } from '../plugin_pack'; +import { + PLUGINS_DIR, + assertInvalidPackError, + assertInvalidDirectoryError +} from './utils'; + + +describe('plugin discovery/plugin_pack', () => { + describe('createPackAtPath$()', () => { + it('returns an observable', () => { + expect(createPackAtPath$()) + .to.have.property('subscribe').a('function'); + }); + it('gets the default provider from prebuilt babel modules', async () => { + const results = await createPackAtPath$(resolve(PLUGINS_DIR, 'prebuilt')).toArray().toPromise(); + expect(results).to.have.length(1); + expect(results[0]).to.only.have.keys(['pack']); + expect(results[0].pack).to.be.a(PluginPack); + }); + describe('errors emitted as { error } results', () => { + async function checkError(path, check) { + const results = await createPackAtPath$(path).toArray().toPromise(); + expect(results).to.have.length(1); + expect(results[0]).to.only.have.keys(['error']); + const { error } = results[0]; + await check(error); + } + it('undefined path', () => checkError(undefined, error => { + assertInvalidDirectoryError(error); + expect(error.message).to.contain('path must be a string'); + })); + it('relative path', () => checkError('plugins/foo', error => { + assertInvalidDirectoryError(error); + expect(error.message).to.contain('path must be absolute'); + })); + it('./relative path', () => checkError('./plugins/foo', error => { + assertInvalidDirectoryError(error); + expect(error.message).to.contain('path must be absolute'); + })); + it('non-existent path', () => checkError(resolve(PLUGINS_DIR, 'baz'), error => { + assertInvalidPackError(error); + expect(error.message).to.contain('must be a directory'); + })); + it('path to a file', () => checkError(resolve(PLUGINS_DIR, 'index.js'), error => { + assertInvalidPackError(error); + expect(error.message).to.contain('must be a directory'); + })); + it('directory without a package.json', () => checkError(resolve(PLUGINS_DIR, 'lib'), error => { + assertInvalidPackError(error); + expect(error.message).to.contain('must have a package.json file'); + })); + it('directory with an invalid package.json', () => checkError(resolve(PLUGINS_DIR, 'broken'), error => { + assertInvalidPackError(error); + expect(error.message).to.contain('must have a valid package.json file'); + })); + it('default export is an object', () => checkError(resolve(PLUGINS_DIR, 'exports_object'), error => { + assertInvalidPackError(error); + expect(error.message).to.contain('must export a function'); + })); + it('default export is an number', () => checkError(resolve(PLUGINS_DIR, 'exports_number'), error => { + assertInvalidPackError(error); + expect(error.message).to.contain('must export a function'); + })); + it('default export is an string', () => checkError(resolve(PLUGINS_DIR, 'exports_string'), error => { + assertInvalidPackError(error); + expect(error.message).to.contain('must export a function'); + })); + }); + }); +}); diff --git a/src/plugin_discovery/plugin_pack/__tests__/packs_in_directory.js b/src/plugin_discovery/plugin_pack/__tests__/packs_in_directory.js new file mode 100644 index 0000000000000..fcd3e96428744 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/packs_in_directory.js @@ -0,0 +1,69 @@ +import { resolve } from 'path'; + +import expect from 'expect.js'; + +import { createPacksInDirectory$ } from '../packs_in_directory'; +import { PluginPack } from '../plugin_pack'; + +import { + PLUGINS_DIR, + assertInvalidDirectoryError, + assertInvalidPackError, +} from './utils'; + +describe('plugin discovery/packs in directory', () => { + describe('createPacksInDirectory$()', () => { + describe('errors emitted as { error } results', () => { + async function checkError(path, check) { + const results = await createPacksInDirectory$(path).toArray().toPromise(); + expect(results).to.have.length(1); + expect(results[0]).to.only.have.keys('error'); + const { error } = results[0]; + await check(error); + } + + it('undefined path', () => checkError(undefined, error => { + assertInvalidDirectoryError(error); + expect(error.message).to.contain('path must be a string'); + })); + it('relative path', () => checkError('my/plugins', error => { + assertInvalidDirectoryError(error); + expect(error.message).to.contain('path must be absolute'); + })); + it('./relative path', () => checkError('./my/pluginsd', error => { + assertInvalidDirectoryError(error); + expect(error.message).to.contain('path must be absolute'); + })); + it('non-existent path', () => checkError(resolve(PLUGINS_DIR, 'notreal'), error => { + assertInvalidDirectoryError(error); + expect(error.message).to.contain('no such file or directory'); + })); + it('path to a file', () => checkError(resolve(PLUGINS_DIR, 'index.js'), error => { + assertInvalidDirectoryError(error); + expect(error.message).to.contain('not a directory'); + })); + }); + + it('includes child errors for invalid packs within a valid directory', async () => { + const results = await createPacksInDirectory$(PLUGINS_DIR).toArray().toPromise(); + + const errors = results + .map(result => result.error) + .filter(Boolean); + + const packs = results + .map(result => result.pack) + .filter(Boolean); + + errors.forEach(assertInvalidPackError); + packs.forEach(pack => expect(pack).to.be.a(PluginPack)); + // there should be one result for each item in PLUGINS_DIR + expect(results).to.have.length(8); + // six of the fixtures are errors of some sorta + expect(errors).to.have.length(6); + // two of them are valid + expect(packs).to.have.length(2); + + }); + }); +}); diff --git a/src/plugin_discovery/plugin_pack/__tests__/plugin_pack.js b/src/plugin_discovery/plugin_pack/__tests__/plugin_pack.js new file mode 100644 index 0000000000000..bb8ca5a7a48bc --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/plugin_pack.js @@ -0,0 +1,110 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; + +import { PluginPack } from '../plugin_pack'; +import { PluginSpec } from '../../plugin_spec'; + +describe('plugin discovery/plugin pack', () => { + describe('constructor', () => { + it('requires an object', () => { + expect(() => { + new PluginPack(); + }).to.throwError(); + }); + }); + describe('#getPkg()', () => { + it('returns the `pkg` constructor argument', () => { + const pkg = {}; + const pack = new PluginPack({ pkg }); + expect(pack.getPkg()).to.be(pkg); + }); + }); + describe('#getPath()', () => { + it('returns the `path` constructor argument', () => { + const path = {}; + const pack = new PluginPack({ path }); + expect(pack.getPath()).to.be(path); + }); + }); + describe('#getPluginSpecs()', () => { + it('calls the `provider` constructor argument with an api including a single sub class of PluginSpec', () => { + const provider = sinon.stub(); + const pack = new PluginPack({ provider }); + sinon.assert.notCalled(provider); + pack.getPluginSpecs(); + sinon.assert.calledOnce(provider); + sinon.assert.calledWithExactly(provider, { + Plugin: sinon.match(Class => { + return Class.prototype instanceof PluginSpec; + }, 'Subclass of PluginSpec') + }); + }); + + it('casts undefined return value to array', () => { + const pack = new PluginPack({ provider: () => undefined }); + expect(pack.getPluginSpecs()).to.eql([]); + }); + + it('casts single PluginSpec to an array', () => { + const pack = new PluginPack({ + path: '/dev/null', + pkg: { name: 'foo', version: 'kibana' }, + provider: ({ Plugin }) => new Plugin({}) + }); + + const specs = pack.getPluginSpecs(); + expect(specs).to.be.an('array'); + expect(specs).to.have.length(1); + expect(specs[0]).to.be.a(PluginSpec); + }); + + it('returns an array of PluginSpec', () => { + const pack = new PluginPack({ + path: '/dev/null', + pkg: { name: 'foo', version: 'kibana' }, + provider: ({ Plugin }) => [ + new Plugin({}), + new Plugin({}), + ] + }); + + const specs = pack.getPluginSpecs(); + expect(specs).to.be.an('array'); + expect(specs).to.have.length(2); + expect(specs[0]).to.be.a(PluginSpec); + expect(specs[1]).to.be.a(PluginSpec); + }); + + it('throws if non-undefined return value is not an instance of api.Plugin', () => { + let OtherPluginSpecClass; + const otherPack = new PluginPack({ + path: '/dev/null', + pkg: { name: 'foo', version: 'kibana' }, + provider: (api) => { + OtherPluginSpecClass = api.Plugin; + } + }); + + // call getPluginSpecs() on other pack to get it's api.Plugin class + otherPack.getPluginSpecs(); + + const badPacks = [ + new PluginPack({ provider: () => false }), + new PluginPack({ provider: () => null }), + new PluginPack({ provider: () => 1 }), + new PluginPack({ provider: () => 'true' }), + new PluginPack({ provider: () => true }), + new PluginPack({ provider: () => new Date() }), + new PluginPack({ provider: () => /foo.*bar/ }), + new PluginPack({ provider: () => function () {} }), + new PluginPack({ provider: () => new OtherPluginSpecClass({}) }), + ]; + + for (const pack of badPacks) { + expect(() => pack.getPluginSpecs()).to.throwError(error => { + expect(error.message).to.contain('unexpected plugin export'); + }); + } + }); + }); +}); diff --git a/src/plugin_discovery/plugin_pack/__tests__/utils.js b/src/plugin_discovery/plugin_pack/__tests__/utils.js new file mode 100644 index 0000000000000..00a692b843745 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/__tests__/utils.js @@ -0,0 +1,18 @@ +import { resolve } from 'path'; +import { inspect } from 'util'; + +import { isInvalidPackError, isInvalidDirectoryError } from '../../errors'; + +export const PLUGINS_DIR = resolve(__dirname, 'fixtures/plugins'); + +export function assertInvalidDirectoryError(error) { + if (!isInvalidDirectoryError(error)) { + throw new Error(`Expected ${inspect(error)} to be an 'InvalidDirectoryError'`); + } +} + +export function assertInvalidPackError(error) { + if (!isInvalidPackError(error)) { + throw new Error(`Expected ${inspect(error)} to be an 'InvalidPackError'`); + } +} diff --git a/src/plugin_discovery/plugin_pack/index.js b/src/plugin_discovery/plugin_pack/index.js new file mode 100644 index 0000000000000..93c552520088e --- /dev/null +++ b/src/plugin_discovery/plugin_pack/index.js @@ -0,0 +1,3 @@ +export { PluginPack } from './plugin_pack'; +export { createPackAtPath$ } from './pack_at_path'; +export { createPacksInDirectory$ } from './packs_in_directory'; diff --git a/src/plugin_discovery/plugin_pack/lib/fs.js b/src/plugin_discovery/plugin_pack/lib/fs.js new file mode 100644 index 0000000000000..d04037d15e069 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/lib/fs.js @@ -0,0 +1,63 @@ +import { stat, readdir } from 'fs'; +import { resolve, isAbsolute } from 'path'; + +import { fromNode as fcb } from 'bluebird'; +import { Observable } from 'rxjs'; + +import { createInvalidDirectoryError } from '../../errors'; + +function assertAbsolutePath(path) { + if (typeof path !== 'string') { + throw createInvalidDirectoryError(new TypeError('path must be a string'), path); + } + + if (!isAbsolute(path)) { + throw createInvalidDirectoryError(new TypeError('path must be absolute'), path); + } +} + +async function statTest(path, test) { + try { + const stats = await fcb(cb => stat(path, cb)); + return Boolean(test(stats)); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + return false; +} + +/** + * Determine if a path currently points to a directory + * @param {String} path + * @return {Promise} + */ +export async function isDirectory(path) { + assertAbsolutePath(path); + return await statTest(path, stat => stat.isDirectory()); +} + +/** + * Get absolute paths for child directories within a path + * @param {string} path + * @return {Promise>} + */ +export const createChildDirectory$ = (path) => ( + Observable + .defer(() => { + assertAbsolutePath(path); + return fcb(cb => readdir(path, cb)); + }) + .catch(error => { + throw createInvalidDirectoryError(error, path); + }) + .mergeAll() + .filter(name => !name.startsWith('.')) + .map(name => resolve(path, name)) + .mergeMap(v => ( + Observable + .fromPromise(isDirectory(path)) + .mergeMap(pass => pass ? [v] : []) + )) +); diff --git a/src/plugin_discovery/plugin_pack/lib/index.js b/src/plugin_discovery/plugin_pack/lib/index.js new file mode 100644 index 0000000000000..662ed0e5e1818 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/lib/index.js @@ -0,0 +1,4 @@ +export { + isDirectory, + createChildDirectory$, +} from './fs'; diff --git a/src/plugin_discovery/plugin_pack/pack_at_path.js b/src/plugin_discovery/plugin_pack/pack_at_path.js new file mode 100644 index 0000000000000..906a0772c1196 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/pack_at_path.js @@ -0,0 +1,41 @@ +import { Observable } from 'rxjs'; +import { resolve } from 'path'; +import { createInvalidPackError } from '../errors'; + +import { isDirectory } from './lib'; +import { PluginPack } from './plugin_pack'; + +async function createPackAtPath(path) { + if (!await isDirectory(path)) { + throw createInvalidPackError(path, 'must be a directory'); + } + + let pkg; + try { + pkg = require(resolve(path, 'package.json')); + } catch (error) { + if (error.code === 'MODULE_NOT_FOUND') { + throw createInvalidPackError(path, 'must have a package.json file'); + } + } + + if (!pkg || typeof pkg !== 'object') { + throw createInvalidPackError(path, 'must have a valid package.json file'); + } + + let provider = require(path); + if (provider.__esModule) { + provider = provider.default; + } + if (typeof provider !== 'function') { + throw createInvalidPackError(path, 'must export a function'); + } + + return new PluginPack({ path, pkg, provider }); +} + +export const createPackAtPath$ = (path) => ( + Observable.defer(() => createPackAtPath(path)) + .map(pack => ({ pack })) + .catch(error => [{ error }]) +); diff --git a/src/plugin_discovery/plugin_pack/packs_in_directory.js b/src/plugin_discovery/plugin_pack/packs_in_directory.js new file mode 100644 index 0000000000000..7078fab4f32f7 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/packs_in_directory.js @@ -0,0 +1,32 @@ +import { isInvalidDirectoryError } from '../errors'; + +import { createChildDirectory$ } from './lib'; +import { createPackAtPath$ } from './pack_at_path'; + +/** + * Finds the plugins within a directory. Results are + * an array of objects with either `pack` or `error` + * keys. + * + * - `{ error }` results are provided when the path is not + * a directory, or one of the child directories is not a + * valid plugin pack. + * - `{ pack }` results are for discovered plugins defs + * + * @param {String} path + * @return {Array<{pack}|{error}>} + */ +export const createPacksInDirectory$ = (path) => ( + createChildDirectory$(path) + .mergeMap(createPackAtPath$) + .catch(error => { + // this error is produced by createChildDirectory$() when the path + // is invalid, we return them as an error result similar to how + // createPackAtPath$ works when it finds invalid packs in a directory + if (isInvalidDirectoryError(error)) { + return [{ error }]; + } + + throw error; + }) +); diff --git a/src/plugin_discovery/plugin_pack/plugin_pack.js b/src/plugin_discovery/plugin_pack/plugin_pack.js new file mode 100644 index 0000000000000..61f109e14b748 --- /dev/null +++ b/src/plugin_discovery/plugin_pack/plugin_pack.js @@ -0,0 +1,55 @@ +import { inspect } from 'util'; + +import { PluginSpec } from '../plugin_spec'; + +export class PluginPack { + constructor({ path, pkg, provider }) { + this._path = path; + this._pkg = pkg; + this._provider = provider; + } + + /** + * Get the contents of this plugin pack's package.json file + * @return {Object} + */ + getPkg() { + return this._pkg; + } + + /** + * Get the absolute path to this plugin pack on disk + * @return {String} + */ + getPath() { + return this._path; + } + + /** + * Invoke the plugin pack's provider to get the list + * of specs defined in this plugin. + * @return {Array} + */ + getPluginSpecs() { + const pack = this; + const api = { + Plugin: class ScopedPluginSpec extends PluginSpec { + constructor(options) { + super(pack, options); + } + } + }; + + const result = this._provider(api); + const specs = [].concat(result === undefined ? [] : result); + + // verify that all specs are instances of passed "Plugin" class + specs.forEach(spec => { + if (!(spec instanceof api.Plugin)) { + throw new TypeError('unexpected plugin export ' + inspect(spec)); + } + }); + + return specs; + } +} diff --git a/src/plugin_discovery/plugin_spec/__tests__/is_version_compatible.js b/src/plugin_discovery/plugin_spec/__tests__/is_version_compatible.js new file mode 100644 index 0000000000000..f8788f0f458c0 --- /dev/null +++ b/src/plugin_discovery/plugin_spec/__tests__/is_version_compatible.js @@ -0,0 +1,29 @@ +import expect from 'expect.js'; + +import { isVersionCompatible } from '../is_version_compatible'; + +describe('plugin discovery/plugin spec', () => { + describe('isVersionCompatible()', () => { + const tests = [ + ['kibana', '6.0.0', true], + ['kibana', '6.0.0-rc1', true], + ['6.0.0-rc1', '6.0.0', true], + ['6.0.0', '6.0.0-rc1', true], + ['6.0.0-rc2', '6.0.0-rc1', true], + ['6.0.0-rc2', '6.0.0-rc3', true], + ['foo', 'bar', false], + ['6.0.0', '5.1.4', false], + ['5.1.4', '6.0.0', false], + ['5.1.4-SNAPSHOT', '6.0.0-rc2-SNAPSHOT', false], + ['5.1.4', '6.0.0-rc2-SNAPSHOT', false], + ['5.1.4-SNAPSHOT', '6.0.0', false], + ['5.1.4-SNAPSHOT', '6.0.0-rc2', false], + ]; + + for (const [plugin, kibana, shouldPass] of tests) { + it(`${shouldPass ? 'should' : `shouldn't`} allow plugin: ${plugin} kibana: ${kibana}`, () => { + expect(isVersionCompatible(plugin, kibana)).to.be(shouldPass); + }); + } + }); +}); diff --git a/src/plugin_discovery/plugin_spec/__tests__/plugin_spec.js b/src/plugin_discovery/plugin_spec/__tests__/plugin_spec.js new file mode 100644 index 0000000000000..2550e30c6501c --- /dev/null +++ b/src/plugin_discovery/plugin_spec/__tests__/plugin_spec.js @@ -0,0 +1,473 @@ +import { resolve } from 'path'; + +import expect from 'expect.js'; +import sinon from 'sinon'; + +import { PluginPack } from '../../plugin_pack'; +import { PluginSpec } from '../plugin_spec'; +import * as IsVersionCompatibleNS from '../is_version_compatible'; + +const fooPack = new PluginPack({ + path: '/dev/null', + pkg: { name: 'foo', version: 'kibana' }, +}); + +describe('plugin discovery/plugin spec', () => { + describe('PluginSpec', () => { + describe('validation', () => { + it('throws if missing spec.id AND Pack has no name', () => { + const pack = new PluginPack({ pkg: {} }); + expect(() => new PluginSpec(pack, {})).to.throwError(error => { + expect(error.message).to.contain('Unable to determine plugin id'); + }); + }); + + it('throws if missing spec.kibanaVersion AND Pack has no version', () => { + const pack = new PluginPack({ pkg: { name: 'foo' } }); + expect(() => new PluginSpec(pack, {})).to.throwError(error => { + expect(error.message).to.contain('Unable to determine plugin version'); + }); + }); + + it('throws if spec.require is defined, but not an array', () => { + function assert(require) { + expect(() => new PluginSpec(fooPack, { require })).to.throwError(error => { + expect(error.message).to.contain('"plugin.require" must be an array of plugin ids'); + }); + } + + assert(null); + assert(''); + assert('kibana'); + assert(1); + assert(0); + assert(/a.*b/); + }); + + it('throws if spec.publicDir is truthy and not a string', () => { + function assert(publicDir) { + expect(() => new PluginSpec(fooPack, { publicDir })).to.throwError(error => { + expect(error.message).to.contain('Path must be a string'); + }); + } + + assert(1); + assert(function () {}); + assert([]); + assert(/a.*b/); + }); + + it('throws if spec.publicDir is not an absolute path', () => { + function assert(publicDir) { + expect(() => new PluginSpec(fooPack, { publicDir })).to.throwError(error => { + expect(error.message).to.contain('plugin.publicDir must be an absolute path'); + }); + } + + assert('relative/path'); + assert('./relative/path'); + }); + + it('throws if spec.publicDir basename is not `public`', () => { + function assert(publicDir) { + expect(() => new PluginSpec(fooPack, { publicDir })).to.throwError(error => { + expect(error.message).to.contain('must end with a "public" directory'); + }); + } + + assert('/www'); + assert('/www/'); + assert('/www/public/my_plugin'); + assert('/www/public/my_plugin/'); + }); + }); + + describe('#getPack()', () => { + it('returns the pack', () => { + const spec = new PluginSpec(fooPack, {}); + expect(spec.getPack()).to.be(fooPack); + }); + }); + + describe('#getPkg()', () => { + it('returns the pkg from the pack', () => { + const spec = new PluginSpec(fooPack, {}); + expect(spec.getPkg()).to.be(fooPack.getPkg()); + }); + }); + + describe('#getPath()', () => { + it('returns the path from the pack', () => { + const spec = new PluginSpec(fooPack, {}); + expect(spec.getPath()).to.be(fooPack.getPath()); + }); + }); + + describe('#getId()', () => { + it('uses spec.id', () => { + const spec = new PluginSpec(fooPack, { + id: 'bar' + }); + + expect(spec.getId()).to.be('bar'); + }); + + it('defaults to pack.pkg.name', () => { + const spec = new PluginSpec(fooPack, {}); + + expect(spec.getId()).to.be('foo'); + }); + }); + + describe('#getVerison()', () => { + it('uses spec.version', () => { + const spec = new PluginSpec(fooPack, { + version: 'bar' + }); + + expect(spec.getVersion()).to.be('bar'); + }); + + it('defaults to pack.pkg.version', () => { + const spec = new PluginSpec(fooPack, {}); + + expect(spec.getVersion()).to.be('kibana'); + }); + }); + + describe('#isEnabled()', () => { + describe('spec.isEnabled is not defined', () => { + function setup(configPrefix, configGetImpl) { + const spec = new PluginSpec(fooPack, { configPrefix }); + const config = { + get: sinon.spy(configGetImpl), + has: sinon.stub() + }; + + return { spec, config }; + } + + it('throws if not passed a config service', () => { + const { spec } = setup('a.b.c', () => true); + + expect(() => spec.isEnabled()).to.throwError(error => { + expect(error.message).to.contain('must be called with a config service'); + }); + expect(() => spec.isEnabled(null)).to.throwError(error => { + expect(error.message).to.contain('must be called with a config service'); + }); + expect(() => spec.isEnabled({ get: () => {} })).to.throwError(error => { + expect(error.message).to.contain('must be called with a config service'); + }); + }); + + it('returns true when config.get([...configPrefix, "enabled"]) returns true', () => { + const { spec, config } = setup('d.e.f', () => true); + + expect(spec.isEnabled(config)).to.be(true); + sinon.assert.calledOnce(config.get); + sinon.assert.calledWithExactly(config.get, ['d', 'e', 'f', 'enabled']); + }); + + it('returns false when config.get([...configPrefix, "enabled"]) returns false', () => { + const { spec, config } = setup('g.h.i', () => false); + + expect(spec.isEnabled(config)).to.be(false); + sinon.assert.calledOnce(config.get); + sinon.assert.calledWithExactly(config.get, ['g', 'h', 'i', 'enabled']); + }); + }); + + describe('spec.isEnabled is defined', () => { + function setup(isEnabledImpl) { + const isEnabled = sinon.spy(isEnabledImpl); + const spec = new PluginSpec(fooPack, { isEnabled }); + const config = { + get: sinon.stub(), + has: sinon.stub() + }; + + return { isEnabled, spec, config }; + } + + it('throws if not passed a config service', () => { + const { spec } = setup(() => true); + + expect(() => spec.isEnabled()).to.throwError(error => { + expect(error.message).to.contain('must be called with a config service'); + }); + expect(() => spec.isEnabled(null)).to.throwError(error => { + expect(error.message).to.contain('must be called with a config service'); + }); + expect(() => spec.isEnabled({ get: () => {} })).to.throwError(error => { + expect(error.message).to.contain('must be called with a config service'); + }); + }); + + it('does not check config if spec.isEnabled returns true', () => { + const { spec, isEnabled, config } = setup(() => true); + + expect(spec.isEnabled(config)).to.be(true); + sinon.assert.calledOnce(isEnabled); + sinon.assert.notCalled(config.get); + }); + + it('does not check config if spec.isEnabled returns false', () => { + const { spec, isEnabled, config } = setup(() => false); + + expect(spec.isEnabled(config)).to.be(false); + sinon.assert.calledOnce(isEnabled); + sinon.assert.notCalled(config.get); + }); + }); + }); + + describe('#getExpectedKibanaVersion()', () => { + describe('has: spec.kibanaVersion,pkg.kibana.version,spec.version,pkg.version', () => { + it('uses spec.kibanaVersion', () => { + const pack = new PluginPack({ + path: '/dev/null', + pkg: { + name: 'expkv', + version: '1.0.0', + kibana: { + version: '6.0.0' + } + } + }); + + const spec = new PluginSpec(pack, { + version: '2.0.0', + kibanaVersion: '5.0.0' + }); + + expect(spec.getExpectedKibanaVersion()).to.be('5.0.0'); + }); + }); + describe('missing: spec.kibanaVersion, has: pkg.kibana.version,spec.version,pkg.version', () => { + it('uses pkg.kibana.version', () => { + const pack = new PluginPack({ + path: '/dev/null', + pkg: { + name: 'expkv', + version: '1.0.0', + kibana: { + version: '6.0.0' + } + } + }); + + const spec = new PluginSpec(pack, { + version: '2.0.0', + }); + + expect(spec.getExpectedKibanaVersion()).to.be('6.0.0'); + }); + }); + describe('missing: spec.kibanaVersion,pkg.kibana.version, has: spec.version,pkg.version', () => { + it('uses spec.version', () => { + const pack = new PluginPack({ + path: '/dev/null', + pkg: { + name: 'expkv', + version: '1.0.0', + } + }); + + const spec = new PluginSpec(pack, { + version: '2.0.0', + }); + + expect(spec.getExpectedKibanaVersion()).to.be('2.0.0'); + }); + }); + describe('missing: spec.kibanaVersion,pkg.kibana.version,spec.version, has: pkg.version', () => { + it('uses pkg.version', () => { + const pack = new PluginPack({ + path: '/dev/null', + pkg: { + name: 'expkv', + version: '1.0.0', + } + }); + + const spec = new PluginSpec(pack, {}); + + expect(spec.getExpectedKibanaVersion()).to.be('1.0.0'); + }); + }); + }); + + describe('#isVersionCompatible()', () => { + it('passes this.getExpectedKibanaVersion() and arg to isVersionCompatible(), returns its result', () => { + const spec = new PluginSpec(fooPack, { version: '1.0.0' }); + sinon.stub(spec, 'getExpectedKibanaVersion').returns('foo'); + const isVersionCompatible = sinon.stub(IsVersionCompatibleNS, 'isVersionCompatible').returns('bar'); + expect(spec.isVersionCompatible('baz')).to.be('bar'); + + sinon.assert.calledOnce(spec.getExpectedKibanaVersion); + sinon.assert.calledWithExactly(spec.getExpectedKibanaVersion); + + sinon.assert.calledOnce(isVersionCompatible); + sinon.assert.calledWithExactly(isVersionCompatible, 'foo', 'baz'); + }); + }); + + describe('#getRequiredPluginIds()', () => { + it('returns spec.require', () => { + const spec = new PluginSpec(fooPack, { require: [1, 2, 3] }); + expect(spec.getRequiredPluginIds()).to.eql([1, 2, 3]); + }); + }); + + describe('#getPublicDir()', () => { + describe('spec.publicDir === false', () => { + it('returns null', () => { + const spec = new PluginSpec(fooPack, { publicDir: false }); + expect(spec.getPublicDir()).to.be(null); + }); + }); + + describe('spec.publicDir is falsy', () => { + it('returns public child of pack path', () => { + function assert(publicDir) { + const spec = new PluginSpec(fooPack, { publicDir }); + expect(spec.getPublicDir()).to.be(resolve('/dev/null/public')); + } + + assert(0); + assert(''); + assert(null); + assert(undefined); + assert(NaN); + }); + }); + + describe('spec.publicDir is an absolute path', () => { + it('returns the path', () => { + const spec = new PluginSpec(fooPack, { + publicDir: '/var/www/public' + }); + + expect(spec.getPublicDir()).to.be('/var/www/public'); + }); + }); + + // NOTE: see constructor tests for other truthy-tests that throw in constructor + }); + + describe('#getExportSpecs()', () => { + it('returns spec.uiExports', () => { + const spec = new PluginSpec(fooPack, { + uiExports: 'foo' + }); + + expect(spec.getExportSpecs()).to.be('foo'); + }); + }); + + describe('#getPreInitHandler()', () => { + it('returns spec.preInit', () => { + const spec = new PluginSpec(fooPack, { + preInit: 'foo' + }); + + expect(spec.getPreInitHandler()).to.be('foo'); + }); + }); + + describe('#getInitHandler()', () => { + it('returns spec.init', () => { + const spec = new PluginSpec(fooPack, { + init: 'foo' + }); + + expect(spec.getInitHandler()).to.be('foo'); + }); + }); + + describe('#getConfigPrefix()', () => { + describe('spec.configPrefix is truthy', () => { + it('returns spec.configPrefix', () => { + const spec = new PluginSpec(fooPack, { + configPrefix: 'foo.bar.baz' + }); + + expect(spec.getConfigPrefix()).to.be('foo.bar.baz'); + }); + }); + describe('spec.configPrefix is falsy', () => { + it('returns spec.getId()', () => { + function assert(configPrefix) { + const spec = new PluginSpec(fooPack, { configPrefix }); + sinon.stub(spec, 'getId').returns('foo'); + expect(spec.getConfigPrefix()).to.be('foo'); + sinon.assert.calledOnce(spec.getId); + } + + assert(false); + assert(null); + assert(undefined); + assert(''); + assert(0); + }); + }); + }); + + describe('#getConfigSchemaProvider()', () => { + it('returns spec.config', () => { + const spec = new PluginSpec(fooPack, { + config: 'foo' + }); + + expect(spec.getConfigSchemaProvider()).to.be('foo'); + }); + }); + + describe('#readConfigValue()', () => { + const spec = new PluginSpec(fooPack, { + configPrefix: 'foo.bar' + }); + + const config = { + get: sinon.stub() + }; + + afterEach(() => config.get.reset()); + + describe('key = "foo"', () => { + it('passes key as own array item', () => { + spec.readConfigValue(config, 'foo'); + sinon.assert.calledOnce(config.get); + sinon.assert.calledWithExactly(config.get, ['foo', 'bar', 'foo']); + }); + }); + + describe('key = "foo.bar"', () => { + it('passes key as two array items', () => { + spec.readConfigValue(config, 'foo.bar'); + sinon.assert.calledOnce(config.get); + sinon.assert.calledWithExactly(config.get, ['foo', 'bar', 'foo', 'bar']); + }); + }); + + describe('key = ["foo", "bar"]', () => { + it('merged keys into array', () => { + spec.readConfigValue(config, ['foo', 'bar']); + sinon.assert.calledOnce(config.get); + sinon.assert.calledWithExactly(config.get, ['foo', 'bar', 'foo', 'bar']); + }); + }); + }); + + describe('#getDeprecationsProvider()', () => { + it('returns spec.deprecations', () => { + const spec = new PluginSpec(fooPack, { + deprecations: 'foo' + }); + + expect(spec.getDeprecationsProvider()).to.be('foo'); + }); + }); + }); +}); diff --git a/src/plugin_discovery/plugin_spec/index.js b/src/plugin_discovery/plugin_spec/index.js new file mode 100644 index 0000000000000..550e9514365ba --- /dev/null +++ b/src/plugin_discovery/plugin_spec/index.js @@ -0,0 +1 @@ +export { PluginSpec } from './plugin_spec'; diff --git a/src/plugin_discovery/plugin_spec/is_version_compatible.js b/src/plugin_discovery/plugin_spec/is_version_compatible.js new file mode 100644 index 0000000000000..139f75566735a --- /dev/null +++ b/src/plugin_discovery/plugin_spec/is_version_compatible.js @@ -0,0 +1,17 @@ +import { + cleanVersion, + versionSatisfies +} from '../../utils/version'; + +export function isVersionCompatible(version, compatibleWith) { + // the special "kibana" version can be used to always be compatible, + // but is intentionally not supported by the plugin installer + if (version === 'kibana') { + return true; + } + + return versionSatisfies( + cleanVersion(version), + cleanVersion(compatibleWith) + ); +} diff --git a/src/plugin_discovery/plugin_spec/plugin_spec.js b/src/plugin_discovery/plugin_spec/plugin_spec.js new file mode 100644 index 0000000000000..40b9c32571c6d --- /dev/null +++ b/src/plugin_discovery/plugin_spec/plugin_spec.js @@ -0,0 +1,175 @@ +import { resolve, basename, isAbsolute as isAbsolutePath } from 'path'; + +import toPath from 'lodash/internal/toPath'; +import { get } from 'lodash'; + +import { createInvalidPluginError } from '../errors'; +import { isVersionCompatible } from './is_version_compatible'; + +export class PluginSpec { + /** + * @param {PluginPack} pack The plugin pack that produced this spec + * @param {Object} opts the options for this plugin + * @param {String} [opts.id=pkg.name] the id for this plugin. + * @param {Object} [opts.uiExports] a mapping of UiExport types to + * UI modules or metadata about the UI module + * @param {Array} [opts.require] the other plugins that this plugin + * requires. These plugins must exist and be enabled for this plugin + * to function. The require'd plugins will also be initialized first, + * in order to make sure that dependencies provided by these plugins + * are available + * @param {String} [opts.version=pkg.version] the version of this plugin + * @param {Function} [opts.init] A function that will be called to initialize + * this plugin at the appropriate time. + * @param {Function} [opts.configPrefix=this.id] The prefix to use for + * configuration values in the main configuration service + * @param {Function} [opts.config] A function that produces a configuration + * schema using Joi, which is passed as its first argument. + * @param {String|False} [opts.publicDir=path + '/public'] the public + * directory for this plugin. The final directory must have the name "public", + * though it can be located somewhere besides the root of the plugin. Set + * this to false to disable exposure of a public directory + */ + constructor(pack, options) { + const { + id, + require, + version, + kibanaVersion, + uiExports, + publicDir, + configPrefix, + config, + deprecations, + preInit, + init, + isEnabled, + } = options; + + this._id = id; + this._pack = pack; + this._version = version; + this._kibanaVersion = kibanaVersion; + this._require = require; + + this._publicDir = publicDir; + this._uiExports = uiExports; + + this._configPrefix = configPrefix; + this._configSchemaProvider = config; + this._configDeprecationsProvider = deprecations; + + this._isEnabled = isEnabled; + this._preInit = preInit; + this._init = init; + + if (!this.getId()) { + throw createInvalidPluginError(this, 'Unable to determine plugin id'); + } + + if (!this.getVersion()) { + throw createInvalidPluginError(this, 'Unable to determine plugin version'); + } + + if (this.getRequiredPluginIds() !== undefined && !Array.isArray(this.getRequiredPluginIds())) { + throw createInvalidPluginError(this, '"plugin.require" must be an array of plugin ids'); + } + + if (this._publicDir) { + if (!isAbsolutePath(this._publicDir)) { + throw createInvalidPluginError(this, 'plugin.publicDir must be an absolute path'); + } + if (basename(this._publicDir) !== 'public') { + throw createInvalidPluginError(this, `publicDir for plugin ${this.getId()} must end with a "public" directory.`); + } + } + } + + getPack() { + return this._pack; + } + + getPkg() { + return this._pack.getPkg(); + } + + getPath() { + return this._pack.getPath(); + } + + getId() { + return this._id || this.getPkg().name; + } + + getVersion() { + return this._version || this.getPkg().version; + } + + isEnabled(config) { + if (!config || typeof config.get !== 'function' || typeof config.has !== 'function') { + throw new TypeError('PluginSpec#isEnabled() must be called with a config service'); + } + + if (this._isEnabled) { + return this._isEnabled(config); + } + + return Boolean(this.readConfigValue(config, 'enabled')); + } + + getExpectedKibanaVersion() { + // Plugins must specify their version, and by default that version should match + // the version of kibana down to the patch level. If these two versions need + // to diverge, they can specify a kibana.version in the package to indicate the + // version of kibana the plugin is intended to work with. + return this._kibanaVersion || get(this.getPack().getPkg(), 'kibana.version') || this.getVersion(); + } + + isVersionCompatible(actualKibanaVersion) { + return isVersionCompatible(this.getExpectedKibanaVersion(), actualKibanaVersion); + } + + getRequiredPluginIds() { + return this._require; + } + + getPublicDir() { + if (this._publicDir === false) { + return null; + } + + if (!this._publicDir) { + return resolve(this.getPack().getPath(), 'public'); + } + + return this._publicDir; + } + + getExportSpecs() { + return this._uiExports; + } + + getPreInitHandler() { + return this._preInit; + } + + getInitHandler() { + return this._init; + } + + getConfigPrefix() { + return this._configPrefix || this.getId(); + } + + getConfigSchemaProvider() { + return this._configSchemaProvider; + } + + readConfigValue(config, key) { + return config.get([...toPath(this.getConfigPrefix()), ...toPath(key)]); + } + + getDeprecationsProvider() { + return this._configDeprecationsProvider; + } +} diff --git a/src/server/config/__tests__/config.js b/src/server/config/__tests__/config.js index 200140adca6c8..53733b52136c2 100644 --- a/src/server/config/__tests__/config.js +++ b/src/server/config/__tests__/config.js @@ -1,4 +1,4 @@ -import Config from '../config'; +import { Config } from '../config'; import expect from 'expect.js'; import _ from 'lodash'; import Joi from 'joi'; diff --git a/src/server/config/config.js b/src/server/config/config.js index c743fcd45f8b9..61276a645f7de 100644 --- a/src/server/config/config.js +++ b/src/server/config/config.js @@ -8,7 +8,7 @@ const schema = Symbol('Joi Schema'); const schemaExts = Symbol('Schema Extensions'); const vals = Symbol('config values'); -export default class Config { +export class Config { static withDefaultSchema(settings = {}) { return new Config(createDefaultSchema(), settings); } diff --git a/src/server/config/index.js b/src/server/config/index.js new file mode 100644 index 0000000000000..88b7273fd1628 --- /dev/null +++ b/src/server/config/index.js @@ -0,0 +1,2 @@ +export { transformDeprecations } from './transform_deprecations'; +export { Config } from './config'; diff --git a/src/server/config/setup.js b/src/server/config/setup.js index bcdca730386ad..d6ecbe562cf56 100644 --- a/src/server/config/setup.js +++ b/src/server/config/setup.js @@ -1,4 +1,4 @@ -import Config from './config'; +import { Config } from './config'; import { transformDeprecations } from './transform_deprecations'; export default function (kbnServer) { diff --git a/src/server/http/index.js b/src/server/http/index.js index 2eb04c425477e..3c3dfb9565627 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -136,7 +136,7 @@ export default async function (kbnServer, server, config) { return; } - const app = kbnServer.uiExports.apps.byId.stateSessionStorageRedirect; + const app = server.getHiddenUiAppById('stateSessionStorageRedirect'); reply.renderApp(app, { redirectUrl: url, }); diff --git a/src/server/kbn_server.js b/src/server/kbn_server.js index 8ed9760701c65..1167823d42500 100644 --- a/src/server/kbn_server.js +++ b/src/server/kbn_server.js @@ -1,28 +1,24 @@ import { constant, once, compact, flatten } from 'lodash'; -import { resolve, fromNode } from 'bluebird'; +import { fromNode } from 'bluebird'; import { isWorker } from 'cluster'; import { fromRoot, pkg } from '../utils'; -import Config from './config/config'; +import { Config } from './config'; import loggingConfiguration from './logging/configuration'; - import configSetupMixin from './config/setup'; import httpMixin from './http'; import loggingMixin from './logging'; import warningsMixin from './warnings'; import statusMixin from './status'; import pidMixin from './pid'; -import pluginsScanMixin from './plugins/scan'; -import pluginsCheckEnabledMixin from './plugins/check_enabled'; -import pluginsCheckVersionMixin from './plugins/check_version'; import configCompleteMixin from './config/complete'; -import uiMixin from '../ui'; import optimizeMixin from '../optimize'; -import pluginsInitializeMixin from './plugins/initialize'; +import * as Plugins from './plugins'; import { indexPatternsMixin } from './index_patterns'; import { savedObjectsMixin } from './saved_objects'; import { statsMixin } from './stats'; import { kibanaIndexMappingsMixin } from './mappings'; import { serverExtensionsMixin } from './server_extensions'; +import { uiMixin } from '../ui'; const rootDir = fromRoot('.'); @@ -35,6 +31,8 @@ export default class KbnServer { this.settings = settings || {}; this.ready = constant(this.mixin( + Plugins.waitForInitSetupMixin, + // sets this.config, reads this.settings configSetupMixin, // sets this.server @@ -51,25 +49,19 @@ export default class KbnServer { // writes pid file pidMixin, - // find plugins and set this.plugins - pluginsScanMixin, - - // disable the plugins that are disabled through configuration - pluginsCheckEnabledMixin, - - // disable the plugins that are incompatible with the current version of Kibana - pluginsCheckVersionMixin, + // find plugins and set this.plugins and this.pluginSpecs + Plugins.scanMixin, // tell the config we are done loading plugins configCompleteMixin, - // setup kbnServer.mappings and server.getKibanaIndexMappingsDsl() - kibanaIndexMappingsMixin, - - // setup this.uiExports and this.bundles + // setup this.uiExports and this.uiBundles uiMixin, indexPatternsMixin, + // setup server.getKibanaIndexMappingsDsl() + kibanaIndexMappingsMixin, + // setup saved object routes savedObjectsMixin, @@ -77,11 +69,15 @@ export default class KbnServer { // lazy bundle server is running optimizeMixin, - // finally, initialize the plugins - pluginsInitializeMixin, + // initialize the plugins + Plugins.initializeMixin, + + // notify any deffered setup logic that plugins have intialized + Plugins.waitForInitResolveMixin, + () => { if (this.config.get('server.autoListen')) { - this.ready = constant(resolve()); + this.ready = constant(Promise.resolve()); return this.listen(); } } @@ -134,17 +130,11 @@ export default class KbnServer { } async inject(opts) { - if (!this.server) await this.ready(); - - return await fromNode(cb => { - try { - this.server.inject(opts, (resp) => { - cb(null, resp); - }); - } catch (err) { - cb(err); - } - }); + if (!this.server) { + await this.ready(); + } + + return await this.server.inject(opts); } applyLoggingConfiguration(settings) { diff --git a/src/server/mappings/__tests__/index_mappings.js b/src/server/mappings/__tests__/index_mappings.js index 999c0eb0d5b3c..981b66e673084 100644 --- a/src/server/mappings/__tests__/index_mappings.js +++ b/src/server/mappings/__tests__/index_mappings.js @@ -64,32 +64,26 @@ describe('server/mapping/index_mapping', function () { } }); }); - }); - - describe('#getDsl()', () => { - // tests are light because this method is used all over these tests - it('returns mapping as es dsl', function () { - const mapping = new IndexMappings(); - expect(mapping.getDsl()).to.be.an('object'); - }); - }); - describe('#addRootProperties()', () => { - it('extends the properties of the root type', () => { - const mapping = new IndexMappings({ + it('accepts an array of new extensions that will be added to the mapping', () => { + const initialMapping = { x: { properties: {} } - }); - - mapping.addRootProperties({ - y: { + }; + const extensions = [ + { properties: { - z: { - type: 'text' + y: { + properties: { + z: { + type: 'text' + } + } } } } - }); + ]; + const mapping = new IndexMappings(initialMapping, extensions); expect(mapping.getDsl()).to.eql({ x: { properties: { @@ -105,24 +99,47 @@ describe('server/mapping/index_mapping', function () { }); }); - it('throws if any property is conflicting', () => { - const props = { foo: 'bar' }; - const mapping = new IndexMappings({ - root: { properties: props } - }); + it('throws if any of the new properties conflict', () => { + const initialMapping = { + root: { properties: { foo: 'bar' } } + }; + const extensions = [ + { + properties: { + foo: 'bar' + } + } + ]; expect(() => { - mapping.addRootProperties(props); + new IndexMappings(initialMapping, extensions); }).to.throwException(/foo/); }); - it('includes the plugin option in the error message when specified', () => { - const props = { foo: 'bar' }; - const mapping = new IndexMappings({ root: { properties: props } }); + it('includes the pluginId from the extension in the error message if defined', () => { + const initialMapping = { + root: { properties: { foo: 'bar' } } + }; + const extensions = [ + { + pluginId: 'abc123', + properties: { + foo: 'bar' + } + } + ]; expect(() => { - mapping.addRootProperties(props, { plugin: 'abc123' }); + new IndexMappings(initialMapping, extensions); }).to.throwException(/plugin abc123/); }); }); + + describe('#getDsl()', () => { + // tests are light because this method is used all over these tests + it('returns mapping as es dsl', function () { + const mapping = new IndexMappings(); + expect(mapping.getDsl()).to.be.an('object'); + }); + }); }); diff --git a/src/server/mappings/index_mappings.js b/src/server/mappings/index_mappings.js index 26aaa9e7c5ad2..5f767b52ff686 100644 --- a/src/server/mappings/index_mappings.js +++ b/src/server/mappings/index_mappings.js @@ -11,7 +11,7 @@ const DEFAULT_INITIAL_DSL = { }; export class IndexMappings { - constructor(initialDsl = DEFAULT_INITIAL_DSL) { + constructor(initialDsl = DEFAULT_INITIAL_DSL, mappingExtensions = []) { this._dsl = cloneDeep(initialDsl); if (!isPlainObject(this._dsl)) { throw new TypeError('initial mapping must be an object'); @@ -20,34 +20,34 @@ export class IndexMappings { // ensure that we have a properties object in the dsl // and that the dsl can be parsed with getRootProperties() and kin this._setProperties(getRootProperties(this._dsl) || {}); - } - - getDsl() { - return cloneDeep(this._dsl); - } - addRootProperties(newProperties, options = {}) { - const { plugin } = options; - const rootProperties = getRootProperties(this._dsl); + // extend this._dsl with each extension (which currently come from uiExports.savedObjectMappings) + mappingExtensions.forEach(({ properties, pluginId }) => { + const rootProperties = getRootProperties(this._dsl); - const conflicts = Object.keys(newProperties) - .filter(key => rootProperties.hasOwnProperty(key)); + const conflicts = Object.keys(properties) + .filter(key => rootProperties.hasOwnProperty(key)); - if (conflicts.length) { - const props = formatListAsProse(conflicts); - const owner = plugin ? `registered by plugin ${plugin} ` : ''; - throw new Error( - `Mappings for ${props} ${owner}have already been defined` - ); - } + if (conflicts.length) { + const props = formatListAsProse(conflicts); + const owner = pluginId ? `registered by plugin ${pluginId} ` : ''; + throw new Error( + `Mappings for ${props} ${owner}have already been defined` + ); + } - this._setProperties({ - ...rootProperties, - ...newProperties + this._setProperties({ + ...rootProperties, + ...properties + }); }); } + getDsl() { + return cloneDeep(this._dsl); + } + _setProperties(newProperties) { const rootType = getRootType(this._dsl); this._dsl = { diff --git a/src/server/mappings/kibana_index_mappings_mixin.js b/src/server/mappings/kibana_index_mappings_mixin.js index e1a169493c950..606aa2c7e4437 100644 --- a/src/server/mappings/kibana_index_mappings_mixin.js +++ b/src/server/mappings/kibana_index_mappings_mixin.js @@ -6,7 +6,7 @@ import { IndexMappings } from './index_mappings'; * and timelion plugins for examples. * @type {EsMappingDsl} */ -const BASE_KIBANA_INDEX_MAPPINGS_DSL = { +const BASE_SAVED_OBJECT_MAPPINGS = { doc: { dynamic: 'strict', properties: { @@ -29,19 +29,10 @@ const BASE_KIBANA_INDEX_MAPPINGS_DSL = { }; export function kibanaIndexMappingsMixin(kbnServer, server) { - /** - * Stores the current mappings that we expect to find in the Kibana - * index. Using `kbnServer.mappings.addRootProperties()` the UiExports - * class extends these mappings based on `mappings` ui export specs. - * - * Application code should not access this object, and instead should - * use `server.getKibanaIndexMappingsDsl()` from below, mixed with the - * helpers exposed by this module, to interact with the mappings via - * their DSL. - * - * @type {IndexMappings} - */ - kbnServer.mappings = new IndexMappings(BASE_KIBANA_INDEX_MAPPINGS_DSL); + const mappings = new IndexMappings( + BASE_SAVED_OBJECT_MAPPINGS, + kbnServer.uiExports.savedObjectMappings + ); /** * Get the mappings dsl that we expect to see in the @@ -57,6 +48,6 @@ export function kibanaIndexMappingsMixin(kbnServer, server) { * @returns {EsMappingDsl} */ server.decorate('server', 'getKibanaIndexMappingsDsl', () => { - return kbnServer.mappings.getDsl(); + return mappings.getDsl(); }); } diff --git a/src/server/plugins/__tests__/plugin_init.js b/src/server/plugins/__tests__/plugin_init.js deleted file mode 100644 index 189a99067b711..0000000000000 --- a/src/server/plugins/__tests__/plugin_init.js +++ /dev/null @@ -1,77 +0,0 @@ -import { values } from 'lodash'; -import expect from 'expect.js'; -import sinon from 'sinon'; -import pluginInit from '../plugin_init'; - -describe('Plugin init', () => { - const getPluginCollection = (plugins) => ({ - byId: plugins, - toArray: () => values(plugins) - }); - - it('should call preInit before init', async () => { - const plugins = { - foo: { - id: 'foo', - init: sinon.spy(), - preInit: sinon.spy(), - requiredIds: [] - }, - bar: { - id: 'bar', - init: sinon.spy(), - preInit: sinon.spy(), - requiredIds: [] - }, - baz: { - id: 'baz', - init: sinon.spy(), - preInit: sinon.spy(), - requiredIds: [] - } - }; - - await pluginInit(getPluginCollection(plugins)); - - expect(plugins.foo.preInit.calledBefore(plugins.foo.init)).to.be.ok(); - expect(plugins.foo.preInit.calledBefore(plugins.bar.init)).to.be.ok(); - expect(plugins.foo.preInit.calledBefore(plugins.baz.init)).to.be.ok(); - - expect(plugins.bar.preInit.calledBefore(plugins.foo.init)).to.be.ok(); - expect(plugins.bar.preInit.calledBefore(plugins.bar.init)).to.be.ok(); - expect(plugins.bar.preInit.calledBefore(plugins.baz.init)).to.be.ok(); - - expect(plugins.baz.preInit.calledBefore(plugins.foo.init)).to.be.ok(); - expect(plugins.baz.preInit.calledBefore(plugins.bar.init)).to.be.ok(); - expect(plugins.baz.preInit.calledBefore(plugins.baz.init)).to.be.ok(); - }); - - it('should call preInits in correct order based on requirements', async () => { - const plugins = { - foo: { - id: 'foo', - init: sinon.spy(), - preInit: sinon.spy(), - requiredIds: ['bar', 'baz'] - }, - bar: { - id: 'bar', - init: sinon.spy(), - preInit: sinon.spy(), - requiredIds: [] - }, - baz: { - id: 'baz', - init: sinon.spy(), - preInit: sinon.spy(), - requiredIds: ['bar'] - } - }; - - await pluginInit(getPluginCollection(plugins)); - - expect(plugins.bar.preInit.firstCall.calledBefore(plugins.foo.init.firstCall)).to.be.ok(); - expect(plugins.bar.preInit.firstCall.calledBefore(plugins.baz.init.firstCall)).to.be.ok(); - expect(plugins.baz.preInit.firstCall.calledBefore(plugins.foo.init.firstCall)).to.be.ok(); - }); -}); diff --git a/src/server/plugins/check_enabled.js b/src/server/plugins/check_enabled.js deleted file mode 100644 index db7b2b80349d5..0000000000000 --- a/src/server/plugins/check_enabled.js +++ /dev/null @@ -1,25 +0,0 @@ -import toPath from 'lodash/internal/toPath'; - -export default async function (kbnServer, server, config) { - const forcedOverride = { - console: function (enabledInConfig) { - return !config.get('elasticsearch.tribe.url') && enabledInConfig; - } - }; - - const { plugins } = kbnServer; - - for (const plugin of plugins) { - const enabledInConfig = config.get([...toPath(plugin.configPrefix), 'enabled']); - const hasOveride = forcedOverride.hasOwnProperty(plugin.id); - if (hasOveride) { - if (!forcedOverride[plugin.id](enabledInConfig)) { - plugins.disable(plugin); - } - } else if (!enabledInConfig) { - plugins.disable(plugin); - } - } - - return; -} diff --git a/src/server/plugins/check_version.js b/src/server/plugins/check_version.js deleted file mode 100644 index d6c50e33e2b09..0000000000000 --- a/src/server/plugins/check_version.js +++ /dev/null @@ -1,36 +0,0 @@ -import { cleanVersion, versionSatisfies } from '../../utils/version'; -import { get } from 'lodash'; - -function compatibleWithKibana(kbnServer, plugin) { - //core plugins have a version of 'kibana' and are always compatible - if (plugin.kibanaVersion === 'kibana') return true; - - const pluginKibanaVersion = cleanVersion(plugin.kibanaVersion); - const kibanaVersion = cleanVersion(kbnServer.version); - - return versionSatisfies(pluginKibanaVersion, kibanaVersion); -} - -export default async function (kbnServer, server) { - //because a plugin pack can contain more than one actual plugin, (for example x-pack) - //we make sure that the warning messages are unique - const warningMessages = new Set(); - const plugins = kbnServer.plugins; - - for (const plugin of plugins) { - const version = plugin.kibanaVersion; - const name = get(plugin, 'pkg.name'); - - if (!compatibleWithKibana(kbnServer, plugin)) { - const message = `Plugin "${name}" was disabled because it expected Kibana version "${version}", and found "${kbnServer.version}".`; - warningMessages.add(message); - plugins.disable(plugin); - } - } - - for (const message of warningMessages) { - server.log(['warning'], message); - } - - return; -} diff --git a/src/server/plugins/index.js b/src/server/plugins/index.js new file mode 100644 index 0000000000000..eb8da1af94752 --- /dev/null +++ b/src/server/plugins/index.js @@ -0,0 +1,3 @@ +export { scanMixin } from './scan_mixin'; +export { initializeMixin } from './initialize_mixin'; +export { waitForInitSetupMixin, waitForInitResolveMixin } from './wait_for_plugins_init'; diff --git a/src/server/plugins/initialize.js b/src/server/plugins/initialize.js deleted file mode 100644 index feb9ca0bbad56..0000000000000 --- a/src/server/plugins/initialize.js +++ /dev/null @@ -1,22 +0,0 @@ -import pluginInit from './plugin_init'; - -export default async function (kbnServer, server, config) { - - if (!config.get('plugins.initialize')) { - server.log(['info'], 'Plugin initialization disabled.'); - return []; - } - - const { plugins } = kbnServer; - - // extend plugin apis with additional context - plugins.getPluginApis().forEach(api => { - - Object.defineProperty(api, 'uiExports', { - value: kbnServer.uiExports - }); - - }); - - await pluginInit(plugins); -} diff --git a/src/server/plugins/initialize_mixin.js b/src/server/plugins/initialize_mixin.js new file mode 100644 index 0000000000000..bd7a30565e719 --- /dev/null +++ b/src/server/plugins/initialize_mixin.js @@ -0,0 +1,27 @@ +import { callPluginHook } from './lib'; + +/** + * KbnServer mixin that initializes all plugins found in ./scan mixin + * @param {KbnServer} kbnServer + * @param {Hapi.Server} server + * @param {Config} config + * @return {Promise} + */ +export async function initializeMixin(kbnServer, server, config) { + if (!config.get('plugins.initialize')) { + server.log(['info'], 'Plugin initialization disabled.'); + return; + } + + async function callHookOnPlugins(hookName) { + const { plugins } = kbnServer; + const ids = plugins.map(p => p.id); + + for (const id of ids) { + await callPluginHook(hookName, plugins, id, []); + } + } + + await callHookOnPlugins('preInit'); + await callHookOnPlugins('init'); +} diff --git a/src/server/plugins/lib/__tests__/call_plugin_hook.js b/src/server/plugins/lib/__tests__/call_plugin_hook.js new file mode 100644 index 0000000000000..4c6ec852e2d55 --- /dev/null +++ b/src/server/plugins/lib/__tests__/call_plugin_hook.js @@ -0,0 +1,87 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { callPluginHook } from '../call_plugin_hook'; + +describe('server/plugins/callPluginHook', () => { + it('should call in correct order based on requirements', async () => { + const plugins = [ + { + id: 'foo', + init: sinon.spy(), + preInit: sinon.spy(), + requiredIds: ['bar', 'baz'] + }, + { + id: 'bar', + init: sinon.spy(), + preInit: sinon.spy(), + requiredIds: [] + }, + { + id: 'baz', + init: sinon.spy(), + preInit: sinon.spy(), + requiredIds: ['bar'] + } + ]; + + await callPluginHook('init', plugins, 'foo', []); + const [foo, bar, baz] = plugins; + sinon.assert.calledOnce(foo.init); + sinon.assert.calledTwice(bar.init); + sinon.assert.calledOnce(baz.init); + sinon.assert.callOrder( + bar.init, + baz.init, + foo.init, + ); + }); + + it('throws meaningful error when required plugin is missing', async () => { + const plugins = [ + { + id: 'foo', + init: sinon.spy(), + preInit: sinon.spy(), + requiredIds: ['bar'] + }, + ]; + + try { + await callPluginHook('init', plugins, 'foo', []); + throw new Error('expected callPluginHook to throw'); + } catch (error) { + expect(error.message).to.contain('"bar" for plugin "foo"'); + } + }); + + it('throws meaningful error when dependencies are circular', async () => { + const plugins = [ + { + id: 'foo', + init: sinon.spy(), + preInit: sinon.spy(), + requiredIds: ['bar'] + }, + { + id: 'bar', + init: sinon.spy(), + preInit: sinon.spy(), + requiredIds: ['baz'] + }, + { + id: 'baz', + init: sinon.spy(), + preInit: sinon.spy(), + requiredIds: ['foo'] + }, + ]; + + try { + await callPluginHook('init', plugins, 'foo', []); + throw new Error('expected callPluginHook to throw'); + } catch (error) { + expect(error.message).to.contain('foo -> bar -> baz -> foo'); + } + }); +}); diff --git a/src/server/plugins/lib/call_plugin_hook.js b/src/server/plugins/lib/call_plugin_hook.js new file mode 100644 index 0000000000000..3ba3bdb846ee6 --- /dev/null +++ b/src/server/plugins/lib/call_plugin_hook.js @@ -0,0 +1,31 @@ +import { last } from 'lodash'; + +export async function callPluginHook(hookName, plugins, id, history) { + const plugin = plugins.find(plugin => plugin.id === id); + + // make sure this is a valid plugin id + if (!plugin) { + if (history.length) { + throw new Error(`Unmet requirement "${id}" for plugin "${last(history)}"`); + } else { + throw new Error(`Unknown plugin "${id}"`); + } + } + + const circleStart = history.indexOf(id); + const path = [...history, id]; + + // make sure we are not trying to load a dependency within itself + if (circleStart > -1) { + const circle = path.slice(circleStart); + throw new Error(`circular dependency found: "${circle.join(' -> ')}"`); + } + + // call hook on all dependencies + for (const req of plugin.requiredIds) { + await callPluginHook(hookName, plugins, req, path); + } + + // call hook on this plugin + await plugin[hookName](); +} diff --git a/src/server/plugins/lib/index.js b/src/server/plugins/lib/index.js new file mode 100644 index 0000000000000..2c65f1d1df9e5 --- /dev/null +++ b/src/server/plugins/lib/index.js @@ -0,0 +1,2 @@ +export { callPluginHook } from './call_plugin_hook'; +export { Plugin } from './plugin'; diff --git a/src/server/plugins/lib/plugin.js b/src/server/plugins/lib/plugin.js new file mode 100644 index 0000000000000..257f16ea79098 --- /dev/null +++ b/src/server/plugins/lib/plugin.js @@ -0,0 +1,103 @@ +import { once } from 'lodash'; + +/** + * The server plugin class, used to extend the server + * and add custom behavior. A "scoped" plugin class is + * created by the PluginApi class and provided to plugin + * providers that automatically binds all but the `opts` + * arguments. + * + * @class Plugin + * @param {KbnServer} kbnServer - the KbnServer this plugin + * belongs to. + * @param {PluginDefinition} def + * @param {PluginSpec} spec + */ +export class Plugin { + constructor(kbnServer, spec) { + this.kbnServer = kbnServer; + this.spec = spec; + this.pkg = spec.getPkg(); + this.path = spec.getPath(); + this.id = spec.getId(); + this.version = spec.getVersion(); + this.requiredIds = spec.getRequiredPluginIds() || []; + this.externalPreInit = spec.getPreInitHandler(); + this.externalInit = spec.getInitHandler(); + this.enabled = spec.isEnabled(kbnServer.config); + this.configPrefix = spec.getConfigPrefix(); + this.publicDir = spec.getPublicDir(); + + this.preInit = once(this.preInit); + this.init = once(this.init); + } + + async preInit() { + if (this.externalPreInit) { + return await this.externalPreInit(this.kbnServer.server); + } + } + + async init() { + const { id, version, kbnServer, configPrefix } = this; + const { config } = kbnServer; + + // setup the hapi register function and get on with it + const asyncRegister = async (server, options) => { + this._server = server; + this._options = options; + + server.log(['plugins', 'debug'], { + tmpl: 'Initializing plugin <%= plugin.toString() %>', + plugin: this + }); + + if (this.publicDir) { + server.exposeStaticDir(`/plugins/${id}/{path*}`, this.publicDir); + } + + // Many of the plugins are simply adding static assets to the server and we don't need + // to track their "status". Since plugins must have an init() function to even set its status + // we shouldn't even create a status unless the plugin can use it. + if (this.externalInit) { + this.status = kbnServer.status.createForPlugin(this); + server.expose('status', this.status); + await this.externalInit(server, options); + } + }; + + const register = (server, options, next) => { + asyncRegister(server, options) + .then(() => next(), next); + }; + + register.attributes = { name: id, version: version }; + + await kbnServer.server.register({ + register: register, + options: config.has(configPrefix) ? config.get(configPrefix) : null + }); + + // Only change the plugin status to green if the + // intial status has not been changed + if (this.status && this.status.state === 'uninitialized') { + this.status.green('Ready'); + } + } + + getServer() { + return this._server; + } + + getOptions() { + return this._options; + } + + toJSON() { + return this.pkg; + } + + toString() { + return `${this.id}@${this.version}`; + } +} diff --git a/src/server/plugins/plugin.js b/src/server/plugins/plugin.js deleted file mode 100644 index f2ffcff0ee354..0000000000000 --- a/src/server/plugins/plugin.js +++ /dev/null @@ -1,175 +0,0 @@ -import _ from 'lodash'; -import Joi from 'joi'; -import Bluebird, { attempt, fromNode } from 'bluebird'; -import { basename, resolve } from 'path'; -import { Deprecations } from '../../deprecation'; - -const extendInitFns = Symbol('extend plugin initialization'); - -const defaultConfigSchema = Joi.object({ - enabled: Joi.boolean().default(true) -}).default(); - -/** - * The server plugin class, used to extend the server - * and add custom behavior. A "scoped" plugin class is - * created by the PluginApi class and provided to plugin - * providers that automatically binds all but the `opts` - * arguments. - * - * @class Plugin - * @param {KbnServer} kbnServer - the KbnServer this plugin - * belongs to. - * @param {String} path - the path from which the plugin hails - * @param {Object} pkg - the value of package.json for the plugin - * @param {Objects} opts - the options for this plugin - * @param {String} [opts.id=pkg.name] - the id for this plugin. - * @param {Object} [opts.uiExports] - a mapping of UiExport types - * to UI modules or metadata about - * the UI module - * @param {Array} [opts.require] - the other plugins that this plugin - * requires. These plugins must exist and - * be enabled for this plugin to function. - * The require'd plugins will also be - * initialized first, in order to make sure - * that dependencies provided by these plugins - * are available - * @param {String} [opts.version=pkg.version] - the version of this plugin - * @param {Function} [opts.init] - A function that will be called to initialize - * this plugin at the appropriate time. - * @param {Function} [opts.configPrefix=this.id] - The prefix to use for configuration - * values in the main configuration service - * @param {Function} [opts.config] - A function that produces a configuration - * schema using Joi, which is passed as its - * first argument. - * @param {String|False} [opts.publicDir=path + '/public'] - * - the public directory for this plugin. The final directory must - * have the name "public", though it can be located somewhere besides - * the root of the plugin. Set this to false to disable exposure of a - * public directory - */ -export default class Plugin { - constructor(kbnServer, path, pkg, opts) { - this.kbnServer = kbnServer; - this.pkg = pkg; - this.path = path; - - this.id = opts.id || pkg.name; - this.uiExportsSpecs = opts.uiExports || {}; - this.requiredIds = opts.require || []; - this.version = opts.version || pkg.version; - - // Plugins must specify their version, and by default that version should match - // the version of kibana down to the patch level. If these two versions need - // to diverge, they can specify a kibana.version in the package to indicate the - // version of kibana the plugin is intended to work with. - this.kibanaVersion = opts.kibanaVersion || _.get(pkg, 'kibana.version', this.version); - this.externalPreInit = opts.preInit || _.noop; - this.externalInit = opts.init || _.noop; - this.configPrefix = opts.configPrefix || this.id; - this.getExternalConfigSchema = opts.config || _.noop; - this.getExternalDeprecations = opts.deprecations || _.noop; - this.preInit = _.once(this.preInit); - this.init = _.once(this.init); - this[extendInitFns] = []; - - if (opts.publicDir === false) { - this.publicDir = null; - } - else if (!opts.publicDir) { - this.publicDir = resolve(this.path, 'public'); - } - else { - this.publicDir = opts.publicDir; - if (basename(this.publicDir) !== 'public') { - throw new Error(`publicDir for plugin ${this.id} must end with a "public" directory.`); - } - } - } - - static scoped(kbnServer, path, pkg) { - return class ScopedPlugin extends Plugin { - constructor(opts) { - super(kbnServer, path, pkg, opts || {}); - } - }; - } - - async getConfigSchema() { - const schema = await this.getExternalConfigSchema(Joi); - return schema || defaultConfigSchema; - } - - getDeprecations() { - const rules = this.getExternalDeprecations(Deprecations); - return rules || []; - } - - async preInit() { - return await this.externalPreInit(this.kbnServer.server); - } - - async init() { - const { id, version, kbnServer, configPrefix } = this; - const { config } = kbnServer; - - // setup the hapi register function and get on with it - const asyncRegister = async (server, options) => { - this.server = server; - - for (const fn of this[extendInitFns]) { - await fn.call(this, server, options); - } - - server.log(['plugins', 'debug'], { - tmpl: 'Initializing plugin <%= plugin.toString() %>', - plugin: this - }); - - if (this.publicDir) { - server.exposeStaticDir(`/plugins/${id}/{path*}`, this.publicDir); - } - - // Many of the plugins are simply adding static assets to the server and we don't need - // to track their "status". Since plugins must have an init() function to even set its status - // we shouldn't even create a status unless the plugin can use it. - if (this.externalInit !== _.noop) { - this.status = kbnServer.status.createForPlugin(this); - server.expose('status', this.status); - } - - return await attempt(this.externalInit, [server, options], this); - }; - - const register = (server, options, next) => { - Bluebird.resolve(asyncRegister(server, options)).nodeify(next); - }; - - register.attributes = { name: id, version: version }; - - await fromNode(cb => { - kbnServer.server.register({ - register: register, - options: config.has(configPrefix) ? config.get(configPrefix) : null - }, cb); - }); - - // Only change the plugin status to green if the - // intial status has not been changed - if (this.status && this.status.state === 'uninitialized') { - this.status.green('Ready'); - } - } - - extendInit(fn) { - this[extendInitFns].push(fn); - } - - toJSON() { - return this.pkg; - } - - toString() { - return `${this.id}@${this.version}`; - } -} diff --git a/src/server/plugins/plugin_api.js b/src/server/plugins/plugin_api.js deleted file mode 100644 index 53e747d775187..0000000000000 --- a/src/server/plugins/plugin_api.js +++ /dev/null @@ -1,30 +0,0 @@ -import Plugin from './plugin'; -import { join } from 'path'; - -export default class PluginApi { - constructor(kibana, pluginPath) { - this.config = kibana.config; - this.rootDir = kibana.rootDir; - this.package = require(join(pluginPath, 'package.json')); - this.Plugin = Plugin.scoped(kibana, pluginPath, this.package); - } - - get uiExports() { - throw new Error('plugin.uiExports is not defined until initialize phase'); - } - - get autoload() { - console.warn( - `${this.package.id} accessed the autoload lists which are no longer available via the Plugin API.` + - 'Use the `ui/autoload/*` modules instead.' - ); - - return { - directives: [], - filters: [], - styles: [], - modules: [], - require: [] - }; - } -} diff --git a/src/server/plugins/plugin_collection.js b/src/server/plugins/plugin_collection.js deleted file mode 100644 index dd7d1afd4ead7..0000000000000 --- a/src/server/plugins/plugin_collection.js +++ /dev/null @@ -1,78 +0,0 @@ - -import PluginApi from './plugin_api'; -import { inspect } from 'util'; -import { get, indexBy } from 'lodash'; -import Collection from '../../utils/collection'; -import { transformDeprecations } from '../config/transform_deprecations'; -import { createTransform } from '../../deprecation'; -import Joi from 'joi'; - -const byIdCache = Symbol('byIdCache'); -const pluginApis = Symbol('pluginApis'); - -async function addPluginConfig(pluginCollection, plugin) { - const { config, server, settings } = pluginCollection.kbnServer; - - const transformedSettings = transformDeprecations(settings); - const pluginSettings = get(transformedSettings, plugin.configPrefix); - const deprecations = plugin.getDeprecations(); - const transformedPluginSettings = createTransform(deprecations)(pluginSettings, (message) => { - server.log(['warning', plugin.configPrefix, 'config', 'deprecation'], message); - }); - - const configSchema = await plugin.getConfigSchema(); - config.extendSchema(configSchema, transformedPluginSettings, plugin.configPrefix); -} - -function disablePluginConfig(pluginCollection, plugin) { - // when disabling a plugin's config we remove the existing schema and - // replace it with a simple schema/config that only has enabled set to false - const { config } = pluginCollection.kbnServer; - config.removeSchema(plugin.configPrefix); - const schema = Joi.object({ enabled: Joi.bool() }); - config.extendSchema(schema, { enabled: false }, plugin.configPrefix); -} - -export default class Plugins extends Collection { - - constructor(kbnServer) { - super(); - this.kbnServer = kbnServer; - this[pluginApis] = new Set(); - } - - async new(path) { - const api = new PluginApi(this.kbnServer, path); - this[pluginApis].add(api); - - const output = [].concat(require(path)(api) || []); - - if (!output.length) return; - - // clear the byIdCache - this[byIdCache] = null; - - for (const plugin of output) { - if (!plugin instanceof api.Plugin) { - throw new TypeError('unexpected plugin export ' + inspect(plugin)); - } - - await addPluginConfig(this, plugin); - this.add(plugin); - } - } - - async disable(plugin) { - disablePluginConfig(this, plugin); - this.delete(plugin); - } - - get byId() { - return this[byIdCache] || (this[byIdCache] = indexBy([...this], 'id')); - } - - getPluginApis() { - return this[pluginApis]; - } - -} diff --git a/src/server/plugins/plugin_init.js b/src/server/plugins/plugin_init.js deleted file mode 100644 index 9a04e9d8f4d7d..0000000000000 --- a/src/server/plugins/plugin_init.js +++ /dev/null @@ -1,35 +0,0 @@ -import { includes } from 'lodash'; - -export default async (plugins) => { - const path = []; - - const initialize = async function (id, fn) { - const plugin = plugins.byId[id]; - - if (includes(path, id)) { - throw new Error(`circular dependencies found: "${path.concat(id).join(' -> ')}"`); - } - - path.push(id); - - for (const reqId of plugin.requiredIds) { - if (!plugins.byId[reqId]) { - throw new Error(`Unmet requirement "${reqId}" for plugin "${id}"`); - } - - await initialize(reqId, fn); - } - - await plugin[fn](); - path.pop(); - }; - - const collection = plugins.toArray(); - for (const { id } of collection) { - await initialize(id, 'preInit'); - } - - for (const { id } of collection) { - await initialize(id, 'init'); - } -}; diff --git a/src/server/plugins/scan.js b/src/server/plugins/scan.js deleted file mode 100644 index 41e406015c70a..0000000000000 --- a/src/server/plugins/scan.js +++ /dev/null @@ -1,59 +0,0 @@ -import _ from 'lodash'; -import { fromNode, each } from 'bluebird'; -import { readdir, stat } from 'fs'; -import { resolve } from 'path'; -import PluginCollection from './plugin_collection'; - -export default async (kbnServer, server, config) => { - - const plugins = kbnServer.plugins = new PluginCollection(kbnServer); - - const scanDirs = [].concat(config.get('plugins.scanDirs') || []); - const pluginPaths = [].concat(config.get('plugins.paths') || []); - - const debug = _.bindKey(server, 'log', ['plugins', 'debug']); - const warning = _.bindKey(server, 'log', ['plugins', 'warning']); - - // scan all scanDirs to find pluginPaths - await each(scanDirs, async dir => { - debug({ tmpl: 'Scanning `<%= dir %>` for plugins', dir: dir }); - - let filenames = null; - - try { - filenames = await fromNode(cb => readdir(dir, cb)); - } catch (err) { - if (err.code !== 'ENOENT') throw err; - - filenames = []; - warning({ - tmpl: '<%= err.code %>: Unable to scan non-existent directory for plugins "<%= dir %>"', - err: err, - dir: dir - }); - } - - await each(filenames, async name => { - if (name[0] === '.') return; - - const path = resolve(dir, name); - const stats = await fromNode(cb => stat(path, cb)); - if (stats.isDirectory()) { - pluginPaths.push(path); - } - }); - }); - - for (const path of pluginPaths) { - let modulePath; - try { - modulePath = require.resolve(path); - } catch (e) { - warning({ tmpl: 'Skipping non-plugin directory at <%= path %>', path: path }); - continue; - } - - await plugins.new(path); - debug({ tmpl: 'Found plugin at <%= path %>', path: modulePath }); - } -}; diff --git a/src/server/plugins/scan_mixin.js b/src/server/plugins/scan_mixin.js new file mode 100644 index 0000000000000..fb5d8960a1147 --- /dev/null +++ b/src/server/plugins/scan_mixin.js @@ -0,0 +1,64 @@ +import { Observable } from 'rxjs'; +import { findPluginSpecs } from '../../plugin_discovery'; + +import { Plugin } from './lib'; + +export async function scanMixin(kbnServer, server, config) { + const { + pack$, + invalidDirectoryError$, + invalidPackError$, + deprecation$, + invalidVersionSpec$, + spec$, + } = findPluginSpecs(kbnServer.settings, config); + + const logging$ = Observable.merge( + pack$.do(definition => { + server.log(['plugin', 'debug'], { + tmpl: 'Found plugin at <%= path %>', + path: definition.getPath() + }); + }), + + invalidDirectoryError$.do(error => { + server.log(['plugin', 'warning'], { + tmpl: '<%= err.code %>: Unable to scan directory for plugins "<%= dir %>"', + err: error, + dir: error.path + }); + }), + + invalidPackError$.do(error => { + server.log(['plugin', 'warning'], { + tmpl: 'Skipping non-plugin directory at <%= path %>', + path: error.path + }); + }), + + invalidVersionSpec$ + .map(spec => { + const name = spec.getId(); + const pluginVersion = spec.getExpectedKibanaVersion(); + const kibanaVersion = config.get('pkg.version'); + return `Plugin "${name}" was disabled because it expected Kibana version "${pluginVersion}", and found "${kibanaVersion}".`; + }) + .distinct() + .do(message => { + server.log(['plugin', 'warning'], message); + }), + + deprecation$.do(({ spec, message }) => { + server.log(['warning', spec.getConfigPrefix(), 'config', 'deprecation'], message); + }) + ); + + kbnServer.pluginSpecs = await spec$ + .merge(logging$.ignoreElements()) + .toArray() + .toPromise(); + + kbnServer.plugins = kbnServer.pluginSpecs.map(spec => ( + new Plugin(kbnServer, spec) + )); +} diff --git a/src/server/plugins/wait_for_plugins_init.js b/src/server/plugins/wait_for_plugins_init.js new file mode 100644 index 0000000000000..da8bfbe13a108 --- /dev/null +++ b/src/server/plugins/wait_for_plugins_init.js @@ -0,0 +1,33 @@ + +/** + * Tracks the individual queue for each kbnServer, rather than attaching + * it to the kbnServer object via a property or something + * @type {WeakMap} + */ +const queues = new WeakMap(); + +export function waitForInitSetupMixin(kbnServer) { + queues.set(kbnServer, []); + + kbnServer.afterPluginsInit = function (callback) { + const queue = queues.get(kbnServer); + + if (!queue) { + throw new Error('Plugins have already initialized. Only use this method for setup logic that must wait for plugins to initialize.'); + } + + queue.push(callback); + }; +} + +export async function waitForInitResolveMixin(kbnServer, server, config) { + const queue = queues.get(kbnServer); + queues.set(kbnServer, null); + + // only actually call the callbacks if we are really initializing + if (config.get('plugins.initialize')) { + for (const cb of queue) { + await cb(); + } + } +} diff --git a/src/server/status/index.js b/src/server/status/index.js index e0c78bebf966f..69f61cfeef0eb 100644 --- a/src/server/status/index.js +++ b/src/server/status/index.js @@ -40,24 +40,23 @@ export default function (kbnServer, server, config) { })); server.decorate('reply', 'renderStatusPage', async function () { - const app = kbnServer.uiExports.getHiddenApp('status_page'); - const response = await getResponse(this); - response.code(kbnServer.status.isGreen() ? 200 : 503); - return response; - - function getResponse(ctx) { - if (app) { - return ctx.renderApp(app); - } - return ctx(kbnServer.status.toString()); + const app = server.getHiddenUiAppById('status_page'); + const reply = this; + const response = app + ? await reply.renderApp(app) + : reply(kbnServer.status.toString()); + + if (response) { + response.code(kbnServer.status.isGreen() ? 200 : 503); + return response; } }); server.route(wrapAuth({ method: 'GET', path: '/status', - handler: function (request, reply) { - return reply.renderStatusPage(); + handler(request, reply) { + reply.renderStatusPage(); } })); } diff --git a/src/ui/__tests__/fixtures/test_app/index.js b/src/ui/__tests__/fixtures/test_app/index.js index 05db665baaf2e..129804e69e490 100644 --- a/src/ui/__tests__/fixtures/test_app/index.js +++ b/src/ui/__tests__/fixtures/test_app/index.js @@ -8,6 +8,12 @@ export default kibana => new kibana.Plugin({ from_test_app: true }; } + }, + + injectDefaultVars() { + return { + from_defaults: true + }; } } }); diff --git a/src/ui/__tests__/ui_app.js b/src/ui/__tests__/ui_app.js deleted file mode 100644 index d7a8889e8afba..0000000000000 --- a/src/ui/__tests__/ui_app.js +++ /dev/null @@ -1,278 +0,0 @@ -import expect from 'expect.js'; -import UiApp from '../ui_app.js'; -import UiExports from '../ui_exports'; -import { noop } from 'lodash'; - -function getMockSpec(extraParams) { - return { - id: 'uiapp-test', - main: 'main.js', - title: 'UIApp Test', - order: 9000, - description: 'Test of UI App Constructor', - icon: 'ui_app_test.svg', - linkToLastSubUrl: true, - hidden: false, - listed: null, - templateName: 'ui_app_test', - ...extraParams - }; -} -describe('UiApp', () => { - describe('constructor', () => { - const uiExports = new UiExports({}); - - it('throws an exception if an ID is not given', () => { - function newAppMissingID() { - const spec = {}; // should have id property - const newApp = new UiApp(uiExports, spec); - return newApp; - } - expect(newAppMissingID).to.throwException(); - }); - - describe('defaults', () => { - const spec = { id: 'uiapp-test-defaults' }; - let newApp; - beforeEach(() => { - newApp = new UiApp(uiExports, spec); - }); - - it('copies the ID from the spec', () => { - expect(newApp.id).to.be(spec.id); - }); - - it('has a default navLink', () => { - expect(newApp.navLink).to.eql({ - id: 'uiapp-test-defaults', - title: undefined, - order: 0, - url: '/app/uiapp-test-defaults', - subUrlBase: '/app/uiapp-test-defaults', - description: undefined, - icon: undefined, - linkToLastSubUrl: true, - hidden: false, - disabled: false, - tooltip: '' - }); - }); - - it('has a default order of 0', () => { - expect(newApp.order).to.be(0); - }); - - it('has a default template name of ui_app', () => { - expect(newApp.templateName).to.be('ui_app'); - }); - }); - - describe('with spec', () => { - const spec = getMockSpec(); - let newApp; - beforeEach(() => { - newApp = new UiApp(uiExports, spec); - }); - - it('copies the ID from the spec', () => { - expect(newApp.id).to.be(spec.id); - }); - - it('copies field values from spec', () => { - // test that the fields exist, but have undefined value - expect(newApp.main).to.be(spec.main); - expect(newApp.title).to.be(spec.title); - expect(newApp.description).to.be(spec.description); - expect(newApp.icon).to.be(spec.icon); - expect(newApp.linkToLastSubUrl).to.be(spec.linkToLastSubUrl); - expect(newApp.templateName).to.be(spec.templateName); - expect(newApp.order).to.be(spec.order); - expect(newApp.navLink).to.eql({ - id: 'uiapp-test', - title: 'UIApp Test', - order: 9000, - url: '/app/uiapp-test', - subUrlBase: '/app/uiapp-test', - description: 'Test of UI App Constructor', - icon: 'ui_app_test.svg', - linkToLastSubUrl: true, - hidden: false, - disabled: false, - tooltip: '' - }); - }); - }); - - describe('reference fields', () => { - const spec = getMockSpec({ testSpec: true }); - let newApp; - beforeEach(() => { - newApp = new UiApp(uiExports, spec); - }); - - it('has a reference to the uiExports object', () => { - expect(newApp.uiExports).to.be(uiExports); - }); - - it('has a reference to the original spec', () => { - expect(newApp.spec).to.be(spec); - }); - - it('has a reference to the spec.injectVars function', () => { - const helloFunction = () => 'hello'; - const spec = { - id: 'uiapp-test', - injectVars: helloFunction - }; - const newApp = new UiApp(uiExports, spec); - expect(newApp.getInjectedVars).to.be(helloFunction); - }); - }); - - describe('app.getInjectedVars', () => { - it('is noop function by default', () => { - const spec = { - id: 'uiapp-test' - }; - const newApp = new UiApp(uiExports, spec); - expect(newApp.getInjectedVars).to.be(noop); - }); - }); - - /* - * The "hidden" and "listed" flags have an bound relationship. The "hidden" - * flag gets cast to a boolean value, and the "listed" flag is dependent on - * "hidden" - */ - describe('hidden flag', () => { - describe('is cast to boolean value', () => { - it('when undefined', () => { - const spec = { - id: 'uiapp-test', - }; - const newApp = new UiApp(uiExports, spec); - expect(newApp.hidden).to.be(false); - }); - - it('when null', () => { - const spec = { - id: 'uiapp-test', - hidden: null, - }; - const newApp = new UiApp(uiExports, spec); - expect(newApp.hidden).to.be(false); - }); - }); - }); - - describe('listed flag', () => { - describe('defaults to the opposite value of hidden', () => { - it(`when it's null and hidden is true`, () => { - const spec = { - id: 'uiapp-test', - hidden: true, - listed: null, - }; - const newApp = new UiApp(uiExports, spec); - expect(newApp.listed).to.be(false); - }); - - it(`when it's null and hidden is false`, () => { - const spec = { - id: 'uiapp-test', - hidden: false, - listed: null, - }; - const newApp = new UiApp(uiExports, spec); - expect(newApp.listed).to.be(true); - }); - - it(`when it's undefined and hidden is false`, () => { - const spec = { - id: 'uiapp-test', - hidden: false, - }; - const newApp = new UiApp(uiExports, spec); - expect(newApp.listed).to.be(true); - }); - - it(`when it's undefined and hidden is true`, () => { - const spec = { - id: 'uiapp-test', - hidden: true, - }; - const newApp = new UiApp(uiExports, spec); - expect(newApp.listed).to.be(false); - }); - }); - - it(`is set to true when it's passed as true`, () => { - const spec = { - id: 'uiapp-test', - listed: true, - }; - const newApp = new UiApp(uiExports, spec); - expect(newApp.listed).to.be(true); - }); - - it(`is set to false when it's passed as false`, () => { - const spec = { - id: 'uiapp-test', - listed: false, - }; - const newApp = new UiApp(uiExports, spec); - expect(newApp.listed).to.be(false); - }); - }); - }); - - describe('getModules', () => { - it('gets modules from uiExports', () => { - const uiExports = new UiExports({}); - uiExports.consumePlugin({ - uiExportsSpecs: { - chromeNavControls: [ 'plugins/ui_app_test/views/nav_control' ], - hacks: [ 'plugins/ui_app_test/hacks/init' ] - } - }); - const spec = getMockSpec(); - const newApp = new UiApp(uiExports, spec); - - expect(newApp.getModules()).to.eql([ - 'main.js', - 'plugins/ui_app_test/views/nav_control', - 'plugins/ui_app_test/hacks/init' - ]); - }); - }); - - describe('toJSON', function () { - it('creates plain object', () => { - const uiExports = new UiExports({}); - const spec = getMockSpec(); - const newApp = new UiApp(uiExports, spec); - - expect(newApp.toJSON()).to.eql({ - id: 'uiapp-test', - title: 'UIApp Test', - description: 'Test of UI App Constructor', - icon: 'ui_app_test.svg', - main: 'main.js', - navLink: { - id: 'uiapp-test', - title: 'UIApp Test', - order: 9000, - url: '/app/uiapp-test', - subUrlBase: '/app/uiapp-test', - description: 'Test of UI App Constructor', - icon: 'ui_app_test.svg', - linkToLastSubUrl: true, - hidden: false, - disabled: false, - tooltip: '' - }, - linkToLastSubUrl: true - }); - }); - }); -}); diff --git a/src/ui/__tests__/ui_exports.js b/src/ui/__tests__/ui_exports.js deleted file mode 100644 index d95d8a8ee9f88..0000000000000 --- a/src/ui/__tests__/ui_exports.js +++ /dev/null @@ -1,107 +0,0 @@ -import expect from 'expect.js'; -import { resolve } from 'path'; - -import UiExports from '../ui_exports'; -import * as kbnTestServer from '../../test_utils/kbn_server'; - -describe('UiExports', function () { - describe('#find()', function () { - it('finds exports based on the passed export names', function () { - const uiExports = new UiExports({}); - uiExports.aliases.foo = ['a', 'b', 'c']; - uiExports.aliases.bar = ['d', 'e', 'f']; - - expect(uiExports.find(['foo'])).to.eql(['a', 'b', 'c']); - expect(uiExports.find(['bar'])).to.eql(['d', 'e', 'f']); - expect(uiExports.find(['foo', 'bar'])).to.eql(['a', 'b', 'c', 'd', 'e', 'f']); - }); - - it('allows query types that match nothing', function () { - const uiExports = new UiExports({}); - uiExports.aliases.foo = ['a', 'b', 'c']; - - expect(uiExports.find(['foo'])).to.eql(['a', 'b', 'c']); - expect(uiExports.find(['bar'])).to.eql([]); - expect(uiExports.find(['foo', 'bar'])).to.eql(['a', 'b', 'c']); - }); - }); - // - describe('#defaultInjectedVars', function () { - describe('two plugins, two sync', function () { - this.slow(10000); - this.timeout(60000); - - let kbnServer; - before(async function () { - kbnServer = kbnTestServer.createServer({ - plugins: { - paths: [ - resolve(__dirname, 'fixtures/plugin_foo'), - resolve(__dirname, 'fixtures/plugin_bar'), - ] - }, - - plugin_foo: { - shared: 'foo' - }, - - plugin_bar: { - shared: 'bar' - } - }); - - await kbnServer.ready(); - }); - - after(async function () { - await kbnServer.close(); - }); - - it('merges the two plugins in the order they are loaded', function () { - expect(kbnServer.uiExports.defaultInjectedVars).to.eql({ - shared: 'foo' - }); - }); - }); - - describe('two plugins, one async', function () { - this.slow(10000); - this.timeout(60000); - - let kbnServer; - before(async function () { - kbnServer = kbnTestServer.createServer({ - plugins: { - scanDirs: [], - paths: [ - resolve(__dirname, 'fixtures/plugin_async_foo'), - resolve(__dirname, 'fixtures/plugin_bar'), - ] - }, - - plugin_async_foo: { - delay: 500, - shared: 'foo' - }, - - plugin_bar: { - shared: 'bar' - } - }); - - await kbnServer.ready(); - }); - - after(async function () { - await kbnServer.close(); - }); - - it('merges the two plugins in the order they are loaded', function () { - // even though plugin_async_foo loads 500ms later, it is still "first" to merge - expect(kbnServer.uiExports.defaultInjectedVars).to.eql({ - shared: 'foo' - }); - }); - }); - }); -}); diff --git a/src/ui/__tests__/ui_exports_replace_injected_vars.js b/src/ui/__tests__/ui_exports_replace_injected_vars.js index 3e69463251e2c..ab751411e7b99 100644 --- a/src/ui/__tests__/ui_exports_replace_injected_vars.js +++ b/src/ui/__tests__/ui_exports_replace_injected_vars.js @@ -18,7 +18,11 @@ const injectReplacer = (kbnServer, replacer) => { // normally the replacer would be defined in a plugin's uiExports, // but that requires stubbing out an entire plugin directory for // each test, so we fake it and jam the replacer into uiExports - kbnServer.uiExports.injectedVarsReplacers.push(replacer); + const { injectedVarsReplacers = [] } = kbnServer.uiExports; + kbnServer.uiExports.injectedVarsReplacers = [ + ...injectedVarsReplacers, + replacer + ]; }; describe('UiExports', function () { @@ -122,7 +126,6 @@ describe('UiExports', function () { it('starts off with the injected vars for the app merged with the default injected vars', async () => { const stub = sinon.stub(); injectReplacer(kbnServer, stub); - kbnServer.uiExports.defaultInjectedVars.from_defaults = true; await kbnServer.inject('/app/test_app'); sinon.assert.calledOnce(stub); diff --git a/src/ui/__tests__/ui_nav_link.js b/src/ui/__tests__/ui_nav_link.js deleted file mode 100644 index c8f9648e6a1c6..0000000000000 --- a/src/ui/__tests__/ui_nav_link.js +++ /dev/null @@ -1,138 +0,0 @@ -import expect from 'expect.js'; - -import UiNavLink from '../ui_nav_link'; - -describe('UiNavLink', () => { - describe('constructor', () => { - it ('initializes the object properties as expected', () => { - const uiExports = { - urlBasePath: 'http://localhost:5601/rnd' - }; - const spec = { - id: 'kibana:discover', - title: 'Discover', - order: -1003, - url: '/app/kibana#/discover', - description: 'interactively explore your data', - icon: 'plugins/kibana/assets/discover.svg', - hidden: true, - disabled: true - }; - const link = new UiNavLink(uiExports, spec); - - expect(link.id).to.be(spec.id); - expect(link.title).to.be(spec.title); - expect(link.order).to.be(spec.order); - expect(link.url).to.be(`${uiExports.urlBasePath}${spec.url}`); - expect(link.description).to.be(spec.description); - expect(link.icon).to.be(spec.icon); - expect(link.hidden).to.be(spec.hidden); - expect(link.disabled).to.be(spec.disabled); - }); - - it ('initializes the url property without a base path when one is not specified in the spec', () => { - const uiExports = {}; - const spec = { - id: 'kibana:discover', - title: 'Discover', - order: -1003, - url: '/app/kibana#/discover', - description: 'interactively explore your data', - icon: 'plugins/kibana/assets/discover.svg', - }; - const link = new UiNavLink(uiExports, spec); - - expect(link.url).to.be(spec.url); - }); - - it ('initializes the order property to 0 when order is not specified in the spec', () => { - const uiExports = {}; - const spec = { - id: 'kibana:discover', - title: 'Discover', - url: '/app/kibana#/discover', - description: 'interactively explore your data', - icon: 'plugins/kibana/assets/discover.svg', - }; - const link = new UiNavLink(uiExports, spec); - - expect(link.order).to.be(0); - }); - - it ('initializes the linkToLastSubUrl property to false when false is specified in the spec', () => { - const uiExports = {}; - const spec = { - id: 'kibana:discover', - title: 'Discover', - order: -1003, - url: '/app/kibana#/discover', - description: 'interactively explore your data', - icon: 'plugins/kibana/assets/discover.svg', - linkToLastSubUrl: false - }; - const link = new UiNavLink(uiExports, spec); - - expect(link.linkToLastSubUrl).to.be(false); - }); - - it ('initializes the linkToLastSubUrl property to true by default', () => { - const uiExports = {}; - const spec = { - id: 'kibana:discover', - title: 'Discover', - order: -1003, - url: '/app/kibana#/discover', - description: 'interactively explore your data', - icon: 'plugins/kibana/assets/discover.svg', - }; - const link = new UiNavLink(uiExports, spec); - - expect(link.linkToLastSubUrl).to.be(true); - }); - - it ('initializes the hidden property to false by default', () => { - const uiExports = {}; - const spec = { - id: 'kibana:discover', - title: 'Discover', - order: -1003, - url: '/app/kibana#/discover', - description: 'interactively explore your data', - icon: 'plugins/kibana/assets/discover.svg', - }; - const link = new UiNavLink(uiExports, spec); - - expect(link.hidden).to.be(false); - }); - - it ('initializes the disabled property to false by default', () => { - const uiExports = {}; - const spec = { - id: 'kibana:discover', - title: 'Discover', - order: -1003, - url: '/app/kibana#/discover', - description: 'interactively explore your data', - icon: 'plugins/kibana/assets/discover.svg', - }; - const link = new UiNavLink(uiExports, spec); - - expect(link.disabled).to.be(false); - }); - - it ('initializes the tooltip property to an empty string by default', () => { - const uiExports = {}; - const spec = { - id: 'kibana:discover', - title: 'Discover', - order: -1003, - url: '/app/kibana#/discover', - description: 'interactively explore your data', - icon: 'plugins/kibana/assets/discover.svg', - }; - const link = new UiNavLink(uiExports, spec); - - expect(link.tooltip).to.be(''); - }); - }); -}); diff --git a/src/ui/app_entry_template.js b/src/ui/app_entry_template.js deleted file mode 100644 index 087609bd53bb2..0000000000000 --- a/src/ui/app_entry_template.js +++ /dev/null @@ -1,29 +0,0 @@ -export default function ({ env, bundle }) { - - const pluginSlug = env.pluginInfo.sort() - .map(p => ' * - ' + p) - .join('\n'); - - const requires = bundle.modules - .map(m => `require('${m}');`) - .join('\n'); - - return ` -/** - * Test entry file - * - * This is programatically created and updated, do not modify - * - * context: ${JSON.stringify(env.context)} - * includes code from: -${pluginSlug} - * - */ - -require('ui/chrome'); -${requires} -require('ui/chrome').bootstrap(/* xoxo */); - -`; - -} diff --git a/src/ui/field_formats/index.js b/src/ui/field_formats/index.js new file mode 100644 index 0000000000000..0ada23204bafa --- /dev/null +++ b/src/ui/field_formats/index.js @@ -0,0 +1,2 @@ +export { fieldFormatsMixin } from './field_formats_mixin'; +export { FieldFormat } from './field_format'; diff --git a/src/ui/i18n/index.js b/src/ui/i18n/index.js deleted file mode 100644 index 4738e20b4facf..0000000000000 --- a/src/ui/i18n/index.js +++ /dev/null @@ -1 +0,0 @@ -export { I18n } from './i18n'; diff --git a/src/ui/index.js b/src/ui/index.js index b29c614e6392c..8982c8fac03ca 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -1,137 +1,2 @@ -import { defaults, _ } from 'lodash'; -import { props, reduce as reduceAsync } from 'bluebird'; -import Boom from 'boom'; -import { resolve } from 'path'; - -import UiExports from './ui_exports'; -import UiBundle from './ui_bundle'; -import UiBundleCollection from './ui_bundle_collection'; -import UiBundlerEnv from './ui_bundler_env'; -import { UiI18n } from './ui_i18n'; - -import { uiSettingsMixin } from './ui_settings'; -import { fieldFormatsMixin } from './field_formats/field_formats_mixin'; - -export default async (kbnServer, server, config) => { - const uiExports = kbnServer.uiExports = new UiExports({ - urlBasePath: config.get('server.basePath'), - kibanaIndexMappings: kbnServer.mappings, - }); - - await kbnServer.mixin(uiSettingsMixin); - - await kbnServer.mixin(fieldFormatsMixin); - - const uiI18n = kbnServer.uiI18n = new UiI18n(config.get('i18n.defaultLocale')); - uiI18n.addUiExportConsumer(uiExports); - - const bundlerEnv = new UiBundlerEnv(config.get('optimize.bundleDir')); - bundlerEnv.addContext('env', config.get('env.name')); - bundlerEnv.addContext('sourceMaps', config.get('optimize.sourceMaps')); - bundlerEnv.addContext('kbnVersion', config.get('pkg.version')); - bundlerEnv.addContext('buildNum', config.get('pkg.buildNum')); - uiExports.addConsumer(bundlerEnv); - - for (const plugin of kbnServer.plugins) { - uiExports.consumePlugin(plugin); - } - - const bundles = kbnServer.bundles = new UiBundleCollection(bundlerEnv, config.get('optimize.bundleFilter')); - - for (const app of uiExports.getAllApps()) { - bundles.addApp(app); - } - - for (const gen of uiExports.getBundleProviders()) { - const bundle = await gen(UiBundle, bundlerEnv, uiExports.getAllApps(), kbnServer.plugins); - if (bundle) bundles.add(bundle); - } - - // render all views from the ui/views directory - server.setupViews(resolve(__dirname, 'views')); - - server.route({ - path: '/app/{id}', - method: 'GET', - async handler(req, reply) { - const id = req.params.id; - const app = uiExports.apps.byId[id]; - if (!app) return reply(Boom.notFound('Unknown app ' + id)); - - try { - if (kbnServer.status.isGreen()) { - await reply.renderApp(app); - } else { - await reply.renderStatusPage(); - } - } catch (err) { - reply(Boom.boomify(err)); - } - } - }); - - async function getKibanaPayload({ app, request, includeUserProvidedConfig, injectedVarsOverrides }) { - const uiSettings = request.getUiSettingsService(); - const translations = await uiI18n.getTranslationsForRequest(request); - - return { - app: app, - nav: uiExports.navLinks.inOrder, - version: kbnServer.version, - branch: config.get('pkg.branch'), - buildNum: config.get('pkg.buildNum'), - buildSha: config.get('pkg.buildSha'), - basePath: config.get('server.basePath'), - serverName: config.get('server.name'), - devMode: config.get('env.dev'), - translations: translations, - uiSettings: await props({ - defaults: uiSettings.getDefaults(), - user: includeUserProvidedConfig && uiSettings.getUserProvided() - }), - vars: await reduceAsync( - uiExports.injectedVarsReplacers, - async (acc, replacer) => await replacer(acc, request, server), - defaults(injectedVarsOverrides, await app.getInjectedVars() || {}, uiExports.defaultInjectedVars) - ), - }; - } - - async function renderApp({ app, reply, includeUserProvidedConfig = true, injectedVarsOverrides = {} }) { - try { - const request = reply.request; - const translations = await uiI18n.getTranslationsForRequest(request); - - return reply.view(app.templateName, { - app, - kibanaPayload: await getKibanaPayload({ - app, - request, - includeUserProvidedConfig, - injectedVarsOverrides - }), - bundlePath: `${config.get('server.basePath')}/bundles`, - i18n: key => _.get(translations, key, ''), - }); - } catch (err) { - reply(err); - } - } - - server.decorate('reply', 'renderApp', function (app, injectedVarsOverrides) { - return renderApp({ - app, - reply: this, - includeUserProvidedConfig: true, - injectedVarsOverrides, - }); - }); - - server.decorate('reply', 'renderAppWithDefaultConfig', function (app) { - return renderApp({ - app, - reply: this, - includeUserProvidedConfig: false, - }); - }); -}; +export { uiMixin } from './ui_mixin'; +export { collectUiExports } from './ui_exports'; diff --git a/src/ui/public/angular-bootstrap/bindHtml/bindHtml.js b/src/ui/public/angular-bootstrap/bindHtml/bindHtml.js index cf635bc375a84..bafc738268626 100755 --- a/src/ui/public/angular-bootstrap/bindHtml/bindHtml.js +++ b/src/ui/public/angular-bootstrap/bindHtml/bindHtml.js @@ -7,4 +7,4 @@ angular.module('ui.bootstrap.bindHtml', []) element.html(value || ''); }); }; - }); \ No newline at end of file + }); diff --git a/src/ui/public/assets/favicons/manifest.json b/src/ui/public/assets/favicons/manifest.json index 25126387919d5..17b3c4b2d9e52 100644 --- a/src/ui/public/assets/favicons/manifest.json +++ b/src/ui/public/assets/favicons/manifest.json @@ -15,4 +15,4 @@ "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" -} \ No newline at end of file +} diff --git a/src/ui/ui_app.js b/src/ui/ui_app.js deleted file mode 100644 index 55fdd11c35b3c..0000000000000 --- a/src/ui/ui_app.js +++ /dev/null @@ -1,69 +0,0 @@ -import { chain, get, noop, once, pick } from 'lodash'; - -export default class UiApp { - constructor(uiExports, spec) { - this.uiExports = uiExports; - this.spec = spec || {}; - - this.id = this.spec.id; - if (!this.id) { - throw new Error('Every app must specify it\'s id'); - } - - this.main = this.spec.main; - this.title = this.spec.title; - this.order = this.spec.order || 0; - this.description = this.spec.description; - this.icon = this.spec.icon; - this.hidden = !!this.spec.hidden; - this.linkToLastSubUrl = this.spec.linkToLastSubUrl; - this.listed = this.spec.listed == null ? !this.hidden : this.spec.listed; - this.templateName = this.spec.templateName || 'ui_app'; - - if (!this.hidden) { - // any non-hidden app has a url, so it gets a "navLink" - this.navLink = this.uiExports.navLinks.new({ - id: this.id, - title: this.title, - order: this.order, - description: this.description, - icon: this.icon, - url: this.spec.url || `/app/${this.id}`, - linkToLastSubUrl: this.linkToLastSubUrl - }); - - if (!this.listed) { - // unlisted apps remove their navLinks from the uiExports collection though - this.uiExports.navLinks.delete(this.navLink); - } - } - - if (this.spec.autoload) { - console.warn( - `"autoload" (used by ${this.id} app) is no longer a valid app configuration directive.` + - 'Use the \`ui/autoload/*\` modules instead.' - ); - } - - // once this resolves, no reason to run it again - this.getModules = once(this.getModules); - - // variables that are injected into the browser, must serialize to JSON - this.getInjectedVars = this.spec.injectVars || noop; - } - - getModules() { - return chain([ - this.uiExports.find(get(this, 'spec.uses', [])), - this.uiExports.find(['chromeNavControls', 'hacks']), - ]) - .flatten() - .uniq() - .unshift(this.main) - .value(); - } - - toJSON() { - return pick(this, ['id', 'title', 'description', 'icon', 'main', 'navLink', 'linkToLastSubUrl']); - } -} diff --git a/src/ui/ui_app_collection.js b/src/ui/ui_app_collection.js deleted file mode 100644 index 979570d92c69f..0000000000000 --- a/src/ui/ui_app_collection.js +++ /dev/null @@ -1,45 +0,0 @@ -import _ from 'lodash'; -import UiApp from './ui_app'; -import Collection from '../utils/collection'; - -const byIdCache = Symbol('byId'); - -export default class UiAppCollection extends Collection { - - constructor(uiExports, parent) { - super(); - - this.uiExports = uiExports; - - if (!parent) { - this.claimedIds = []; - this.hidden = new UiAppCollection(uiExports, this); - } else { - this.claimedIds = parent.claimedIds; - } - - } - - new(spec) { - if (this.hidden && spec.hidden) { - return this.hidden.new(spec); - } - - const app = new UiApp(this.uiExports, spec); - - if (_.includes(this.claimedIds, app.id)) { - throw new Error('Unable to create two apps with the id ' + app.id + '.'); - } else { - this.claimedIds.push(app.id); - } - - this[byIdCache] = null; - this.add(app); - return app; - } - - get byId() { - return this[byIdCache] || (this[byIdCache] = _.indexBy([...this], 'id')); - } - -} diff --git a/src/ui/ui_apps/__tests__/ui_app.js b/src/ui/ui_apps/__tests__/ui_app.js new file mode 100644 index 0000000000000..fedcdca6f6108 --- /dev/null +++ b/src/ui/ui_apps/__tests__/ui_app.js @@ -0,0 +1,357 @@ +import sinon from 'sinon'; +import expect from 'expect.js'; + +import { UiApp } from '../ui_app'; +import { UiNavLink } from '../../ui_nav_links'; + +function createStubUiAppSpec(extraParams) { + return { + id: 'uiapp-test', + main: 'main.js', + title: 'UIApp Test', + order: 9000, + description: 'Test of UI App Constructor', + icon: 'ui_app_test.svg', + linkToLastSubUrl: true, + hidden: false, + listed: false, + templateName: 'ui_app_test', + uses: [ + 'visTypes', + 'chromeNavControls', + 'hacks', + ], + injectVars() { + return { foo: 'bar' }; + }, + ...extraParams + }; +} + +function createStubKbnServer() { + return { + plugins: [], + uiExports: { + appExtensions: { + hacks: [ + 'plugins/foo/hack' + ] + } + }, + config: { + get: sinon.stub() + .withArgs('server.basePath') + .returns('') + }, + server: {} + }; +} + +function createUiApp(spec = createStubUiAppSpec(), kbnServer = createStubKbnServer()) { + return new UiApp(kbnServer, spec); +} + +describe('UiApp', () => { + describe('constructor', () => { + it('throws an exception if an ID is not given', () => { + const spec = {}; // should have id property + expect(() => createUiApp(spec)).to.throwException(); + }); + + describe('defaults', () => { + const spec = { id: 'uiapp-test-defaults' }; + const app = createUiApp(spec); + + it('has the ID from the spec', () => { + expect(app.getId()).to.be(spec.id); + }); + + it('has no plugin ID', () => { + expect(app.getPluginId()).to.be(undefined); + }); + + it('has a default template name of ui_app', () => { + expect(app.getTemplateName()).to.be('ui_app'); + }); + + it('is not hidden', () => { + expect(app.isHidden()).to.be(false); + }); + + it('is listed', () => { + expect(app.isListed()).to.be(true); + }); + + it('has a navLink', () => { + expect(app.getNavLink()).to.be.a(UiNavLink); + }); + + it('has no injected vars', () => { + expect(app.getInjectedVars()).to.be(undefined); + }); + + it('has an empty modules list', () => { + expect(app.getModules()).to.eql([]); + }); + + it('has a mostly empty JSON representation', () => { + expect(JSON.parse(JSON.stringify(app))).to.eql({ + id: spec.id, + navLink: { + id: 'uiapp-test-defaults', + order: 0, + url: '/app/uiapp-test-defaults', + subUrlBase: '/app/uiapp-test-defaults', + linkToLastSubUrl: true, + hidden: false, + disabled: false, + tooltip: '' + }, + }); + }); + }); + + describe('mock spec', () => { + const spec = createStubUiAppSpec(); + const app = createUiApp(spec); + + it('has the ID from the spec', () => { + expect(app.getId()).to.be(spec.id); + }); + + it('has no plugin ID', () => { + expect(app.getPluginId()).to.be(undefined); + }); + + it('uses the specs template', () => { + expect(app.getTemplateName()).to.be(spec.templateName); + }); + + it('is not hidden', () => { + expect(app.isHidden()).to.be(false); + }); + + it('is also not listed', () => { + expect(app.isListed()).to.be(false); + }); + + it('has no navLink', () => { + expect(app.getNavLink()).to.be(undefined); + }); + + it('has injected vars', () => { + expect(app.getInjectedVars()).to.eql({ foo: 'bar' }); + }); + + it('includes main and hack modules', () => { + expect(app.getModules()).to.eql([ + 'main.js', + 'plugins/foo/hack' + ]); + }); + + it('has spec values in JSON representation', () => { + expect(JSON.parse(JSON.stringify(app))).to.eql({ + id: spec.id, + title: spec.title, + description: spec.description, + icon: spec.icon, + main: spec.main, + linkToLastSubUrl: spec.linkToLastSubUrl, + navLink: { + id: 'uiapp-test', + title: 'UIApp Test', + order: 9000, + url: '/app/uiapp-test', + subUrlBase: '/app/uiapp-test', + description: 'Test of UI App Constructor', + icon: 'ui_app_test.svg', + linkToLastSubUrl: true, + hidden: false, + disabled: false, + tooltip: '' + }, + }); + }); + }); + + /* + * The "hidden" and "listed" flags have an bound relationship. The "hidden" + * flag gets cast to a boolean value, and the "listed" flag is dependent on + * "hidden" + */ + describe('hidden flag', () => { + describe('is cast to boolean value', () => { + it('when undefined', () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isHidden()).to.be(false); + }); + + it('when null', () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + hidden: null, + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isHidden()).to.be(false); + }); + + it('when 0', () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + hidden: 0, + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isHidden()).to.be(false); + }); + + it('when true', () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + hidden: true, + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isHidden()).to.be(true); + }); + + it('when 1', () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + hidden: 1, + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isHidden()).to.be(true); + }); + }); + }); + + describe('listed flag', () => { + describe('defaults to the opposite value of hidden', () => { + it(`when it's null and hidden is true`, () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + hidden: true, + listed: null, + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isListed()).to.be(false); + }); + + it(`when it's null and hidden is false`, () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + hidden: false, + listed: null, + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isListed()).to.be(true); + }); + + it(`when it's undefined and hidden is false`, () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + hidden: false, + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isListed()).to.be(true); + }); + + it(`when it's undefined and hidden is true`, () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + hidden: true, + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isListed()).to.be(false); + }); + }); + + it(`is set to true when it's passed as true`, () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + listed: true, + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isListed()).to.be(true); + }); + + it(`is set to false when it's passed as false`, () => { + const kbnServer = createStubKbnServer(); + const spec = { + id: 'uiapp-test', + listed: false, + }; + const newApp = new UiApp(kbnServer, spec); + expect(newApp.isListed()).to.be(false); + }); + }); + }); + + describe('pluginId', () => { + describe('not specified', () => { + it('passes the root server and undefined for plugin/optoins to injectVars()', () => { + const injectVars = sinon.stub(); + const kbnServer = createStubKbnServer(); + createUiApp(createStubUiAppSpec({ injectVars }), kbnServer).getInjectedVars(); + + sinon.assert.calledOnce(injectVars); + sinon.assert.calledOn(injectVars, sinon.match.same(undefined)); + sinon.assert.calledWithExactly( + injectVars, + // server arg, uses root server because there is no plugin + sinon.match.same(kbnServer.server), + // options is undefined because there is no plugin + sinon.match.same(undefined) + ); + }); + }); + describe('matches a kbnServer plugin', () => { + it('passes the plugin/server/options from the plugin to injectVars()', () => { + const server = {}; + const options = {}; + const plugin = { + id: 'test plugin id', + getServer() { + return server; + }, + getOptions() { + return options; + } + }; + + const kbnServer = createStubKbnServer(); + kbnServer.plugins.push(plugin); + + const injectVars = sinon.stub(); + const spec = createStubUiAppSpec({ pluginId: plugin.id, injectVars }); + createUiApp(spec, kbnServer).getInjectedVars(); + + sinon.assert.calledOnce(injectVars); + sinon.assert.calledOn(injectVars, sinon.match.same(plugin)); + sinon.assert.calledWithExactly(injectVars, sinon.match.same(server), sinon.match.same(options)); + }); + }); + describe('does not match a kbnServer plugin', () => { + it('throws an error at instantiation', () => { + expect(() => { + createUiApp(createStubUiAppSpec({ pluginId: 'foo' })); + }).to.throwException((error) => { + expect(error.message).to.match(/Unknown plugin id/); + }); + }); + }); + }); +}); diff --git a/src/ui/ui_apps/index.js b/src/ui/ui_apps/index.js new file mode 100644 index 0000000000000..d2914dc94e052 --- /dev/null +++ b/src/ui/ui_apps/index.js @@ -0,0 +1 @@ +export { uiAppsMixin } from './ui_apps_mixin'; diff --git a/src/ui/ui_apps/ui_app.js b/src/ui/ui_apps/ui_app.js new file mode 100644 index 0000000000000..7c8c6e70a494a --- /dev/null +++ b/src/ui/ui_apps/ui_app.js @@ -0,0 +1,144 @@ +import { UiNavLink } from '../ui_nav_links'; + +export class UiApp { + constructor(kbnServer, spec) { + const { + pluginId, + id = pluginId, + main, + title, + order = 0, + description, + icon, + hidden, + linkToLastSubUrl, + listed, + templateName = 'ui_app', + injectVars, + url = `/app/${id}`, + uses = [] + } = spec; + + if (!id) { + throw new Error('Every app must specify an id'); + } + + this._id = id; + this._main = main; + this._title = title; + this._order = order; + this._description = description; + this._icon = icon; + this._linkToLastSubUrl = linkToLastSubUrl; + this._hidden = hidden; + this._listed = listed; + this._templateName = templateName; + this._url = url; + this._injectedVarsProvider = injectVars; + this._pluginId = pluginId; + this._kbnServer = kbnServer; + + if (this._pluginId && !this._getPlugin()) { + throw new Error(`Unknown plugin id "${this._pluginId}"`); + } + + const { appExtensions = [] } = kbnServer.uiExports; + this._modules = [] + .concat(this._main, ...uses.map(type => appExtensions[type] || [])) + .filter(Boolean) + .reduce((modules, item) => ( + modules.includes(item) + ? modules + : modules.concat(item) + ), []); + + if (!this.isHidden()) { + // unless an app is hidden it gets a navlink, but we only respond to `getNavLink()` + // if the app is also listed. This means that all apps in the kibanaPayload will + // have a navLink property since that list includes all normally accessible apps + this._navLink = new UiNavLink(kbnServer.config.get('server.basePath'), { + id: this._id, + title: this._title, + order: this._order, + description: this._description, + icon: this._icon, + url: this._url, + linkToLastSubUrl: this._linkToLastSubUrl + }); + } + } + + getId() { + return this._id; + } + + getPluginId() { + const plugin = this._getPlugin(); + return plugin ? plugin.id : undefined; + } + + getTemplateName() { + return this._templateName; + } + + isHidden() { + return !!this._hidden; + } + + isListed() { + return ( + !this.isHidden() && + (this._listed == null || !!this._listed) + ); + } + + getNavLink() { + if (this.isListed()) { + return this._navLink; + } + } + + getInjectedVars() { + const provider = this._injectedVarsProvider; + const plugin = this._getPlugin(); + + if (!provider) { + return; + } + + return provider.call( + plugin, + plugin + ? plugin.getServer() + : this._kbnServer.server, + plugin + ? plugin.getOptions() + : undefined + ); + } + + getModules() { + return this._modules; + } + + _getPlugin() { + const pluginId = this._pluginId; + const { plugins } = this._kbnServer; + + return pluginId + ? plugins.find(plugin => plugin.id === pluginId) + : undefined; + } + + toJSON() { + return { + id: this._id, + title: this._title, + description: this._description, + icon: this._icon, + main: this._main, + navLink: this._navLink, + linkToLastSubUrl: this._linkToLastSubUrl + }; + } +} diff --git a/src/ui/ui_apps/ui_apps_mixin.js b/src/ui/ui_apps/ui_apps_mixin.js new file mode 100644 index 0000000000000..a3d20e9fbffb1 --- /dev/null +++ b/src/ui/ui_apps/ui_apps_mixin.js @@ -0,0 +1,32 @@ +import { UiApp } from './ui_app'; + +export function uiAppsMixin(kbnServer, server) { + + const { uiAppSpecs = [] } = kbnServer.uiExports; + const existingIds = new Set(); + const appsById = new Map(); + const hiddenAppsById = new Map(); + + kbnServer.uiApps = uiAppSpecs.map((spec) => { + const app = new UiApp(kbnServer, spec); + const id = app.getId(); + + if (!existingIds.has(id)) { + existingIds.add(id); + } else { + throw new Error(`Unable to create two apps with the id ${id}.`); + } + + if (app.isHidden()) { + hiddenAppsById.set(id, app); + } else { + appsById.set(id, app); + } + + return app; + }); + + server.decorate('server', 'getAllUiApps', () => kbnServer.uiApps.slice(0)); + server.decorate('server', 'getUiAppById', id => appsById.get(id)); + server.decorate('server', 'getHiddenUiAppById', id => hiddenAppsById.get(id)); +} diff --git a/src/ui/ui_bundle.js b/src/ui/ui_bundle.js deleted file mode 100644 index 4d3f6e6f49d23..0000000000000 --- a/src/ui/ui_bundle.js +++ /dev/null @@ -1,68 +0,0 @@ -import { join } from 'path'; -import { promisify } from 'bluebird'; - -const read = promisify(require('fs').readFile); -const write = promisify(require('fs').writeFile); -const unlink = promisify(require('fs').unlink); -const stat = promisify(require('fs').stat); - -export default class UiBundle { - constructor(opts) { - - opts = opts || {}; - this.id = opts.id; - this.modules = opts.modules; - this.template = opts.template; - this.env = opts.env; - - const pathBase = join(this.env.workingDir, this.id); - this.entryPath = `${pathBase}.entry.js`; - this.outputPath = `${pathBase}.bundle.js`; - - } - - renderContent() { - return this.template({ - env: this.env, - bundle: this - }); - } - - async readEntryFile() { - try { - const content = await read(this.entryPath); - return content.toString('utf8'); - } - catch (e) { - return null; - } - } - - async writeEntryFile() { - return await write(this.entryPath, this.renderContent(), { encoding: 'utf8' }); - } - - async clearBundleFile() { - try { await unlink(this.outputPath); } - catch (e) { return null; } - } - - async checkForExistingOutput() { - try { - await stat(this.outputPath); - return true; - } - catch (e) { - return false; - } - } - - toJSON() { - return { - id: this.id, - modules: this.modules, - entryPath: this.entryPath, - outputPath: this.outputPath - }; - } -} diff --git a/src/ui/ui_bundle_collection.js b/src/ui/ui_bundle_collection.js deleted file mode 100644 index b4ce0992e2617..0000000000000 --- a/src/ui/ui_bundle_collection.js +++ /dev/null @@ -1,108 +0,0 @@ -import { createHash } from 'crypto'; - -import UiBundle from './ui_bundle'; -import appEntryTemplate from './app_entry_template'; -import { transform, pluck } from 'lodash'; -import { promisify } from 'bluebird'; -import { makeRe } from 'minimatch'; - -const mkdirp = promisify(require('mkdirp')); - -export default class UiBundleCollection { - constructor(bundlerEnv, filter) { - this.each = []; - this.env = bundlerEnv; - this.filter = makeRe(filter || '*', { - noglobstar: true, - noext: true, - matchBase: true - }); - } - - add(bundle) { - if (!(bundle instanceof UiBundle)) { - throw new TypeError('expected bundle to be an instance of UiBundle'); - } - - if (this.filter.test(bundle.id)) { - this.each.push(bundle); - } - } - - addApp(app) { - this.add(new UiBundle({ - id: app.id, - modules: app.getModules(), - template: appEntryTemplate, - env: this.env - })); - } - - desc() { - switch (this.each.length) { - case 0: - return '0 bundles'; - case 1: - return `bundle for ${this.each[0].id}`; - default: - const ids = this.getIds(); - const last = ids.pop(); - const commas = ids.join(', '); - return `bundles for ${commas} and ${last}`; - } - } - - async ensureDir() { - await mkdirp(this.env.workingDir); - } - - async writeEntryFiles() { - await this.ensureDir(); - - for (const bundle of this.each) { - const existing = await bundle.readEntryFile(); - const expected = bundle.renderContent(); - - if (existing !== expected) { - await bundle.writeEntryFile(); - await bundle.clearBundleFile(); - } - } - } - - hashBundleEntries() { - const hash = createHash('sha1'); - for (const bundle of this.each) { - hash.update(`bundleEntryPath:${bundle.entryPath}`); - hash.update(`bundleEntryContent:${bundle.renderContent()}`); - } - return hash.digest('hex'); - } - - async getInvalidBundles() { - const invalids = new UiBundleCollection(this.env); - - for (const bundle of this.each) { - const exists = await bundle.checkForExistingOutput(); - if (!exists) { - invalids.add(bundle); - } - } - - return invalids; - } - - toWebpackEntries() { - return transform(this.each, function (entries, bundle) { - entries[bundle.id] = bundle.entryPath; - }, {}); - } - - getIds() { - return pluck(this.each, 'id'); - } - - toJSON() { - return this.each; - } -} diff --git a/src/ui/ui_bundler_env.js b/src/ui/ui_bundler_env.js deleted file mode 100644 index b0bdc3b890e43..0000000000000 --- a/src/ui/ui_bundler_env.js +++ /dev/null @@ -1,99 +0,0 @@ -import { fromRoot } from '../utils'; -import { includes, escapeRegExp } from 'lodash'; -import { isAbsolute } from 'path'; - -const arr = v => [].concat(v || []); - -export default class UiBundlerEnv { - constructor(workingDir) { - - // the location that bundle entry files and all compiles files will - // be written - this.workingDir = workingDir; - - // the context that the bundler is running in, this is not officially - // used for anything but it is serialized into the entry file to ensure - // that they are invalidated when the context changes - this.context = {}; - - // the plugins that are used to build this environment - // are tracked and embedded into the entry file so that when the - // environment changes we can rebuild the bundles - this.pluginInfo = []; - - // regular expressions which will prevent webpack from parsing the file - this.noParse = [ - /node_modules[\/\\](angular|elasticsearch-browser)[\/\\]/, - /node_modules[\/\\](mocha|moment)[\/\\]/ - ]; - - // webpack aliases, like require paths, mapping a prefix to a directory - this.aliases = { - ui: fromRoot('src/ui/public'), - ui_framework: fromRoot('ui_framework'), - packages: fromRoot('packages'), - test_harness: fromRoot('src/test_harness/public'), - querystring: 'querystring-browser', - moment$: fromRoot('webpackShims/moment'), - 'moment-timezone$': fromRoot('webpackShims/moment-timezone') - }; - - // map of which plugins created which aliases - this.aliasOwners = {}; - - // loaders that are applied to webpack modules after all other processing - // NOTE: this is intentionally not exposed as a uiExport because it leaks - // too much of the webpack implementation to plugins, but is used by test_bundle - // core plugin to inject the instrumentation loader - this.postLoaders = []; - } - - consumePlugin(plugin) { - const tag = `${plugin.id}@${plugin.version}`; - if (includes(this.pluginInfo, tag)) return; - - if (plugin.publicDir) { - this.aliases[`plugins/${plugin.id}`] = plugin.publicDir; - } - - this.pluginInfo.push(tag); - } - - exportConsumer(type) { - switch (type) { - case 'noParse': - return (plugin, spec) => { - for (const rule of arr(spec)) { - this.noParse.push(this.getNoParseRegexp(rule)); - } - }; - - case '__globalImportAliases__': - return (plugin, spec) => { - for (const key of Object.keys(spec)) { - this.aliases[key] = spec[key]; - } - }; - } - } - - addContext(key, val) { - this.context[key] = val; - } - - addPostLoader(loader) { - this.postLoaders.push(loader); - } - - getNoParseRegexp(rule) { - if (typeof rule === 'string') { - return new RegExp(`${isAbsolute(rule) ? '^' : ''}${escapeRegExp(rule)}`); - } - - if (rule instanceof RegExp) { - return rule; - } - - throw new Error('Expected noParse rule to be a string or regexp'); - } -} diff --git a/src/ui/ui_bundles/app_entry_template.js b/src/ui/ui_bundles/app_entry_template.js new file mode 100644 index 0000000000000..2f2715e9634d0 --- /dev/null +++ b/src/ui/ui_bundles/app_entry_template.js @@ -0,0 +1,14 @@ +export const appEntryTemplate = (bundle) => ` +/** + * Test entry file + * + * This is programatically created and updated, do not modify + * + * context: ${bundle.getContext()} + */ + +require('ui/chrome'); +${bundle.getRequires().join('\n')} +require('ui/chrome').bootstrap(/* xoxo */); + +`; diff --git a/src/ui/ui_bundles/index.js b/src/ui/ui_bundles/index.js new file mode 100644 index 0000000000000..b9207b5204dbf --- /dev/null +++ b/src/ui/ui_bundles/index.js @@ -0,0 +1 @@ +export { uiBundlesMixin } from './ui_bundles_mixin'; diff --git a/src/ui/ui_bundles/ui_bundle.js b/src/ui/ui_bundles/ui_bundle.js new file mode 100644 index 0000000000000..aa9f2892a9069 --- /dev/null +++ b/src/ui/ui_bundles/ui_bundle.js @@ -0,0 +1,87 @@ +import { fromNode as fcb } from 'bluebird'; +import { readFile, writeFile, unlink, stat } from 'fs'; + +export class UiBundle { + constructor(options) { + const { + id, + modules, + template, + controller, + } = options; + + this._id = id; + this._modules = modules; + this._template = template; + this._controller = controller; + } + + getId() { + return this._id; + } + + getContext() { + return this._controller.getContext(); + } + + getEntryPath() { + return this._controller.resolvePath(`${this.getId()}.entry.js`); + } + + getOutputPath() { + return this._controller.resolvePath(`${this.getId()}.bundle.js`); + } + + getRequires() { + return this._modules.map(module => ( + `require('${module}');` + )); + } + + renderContent() { + return this._template(this); + } + + async readEntryFile() { + try { + const content = await fcb(cb => readFile(this.getEntryPath(), cb)); + return content.toString('utf8'); + } + catch (e) { + return null; + } + } + + async writeEntryFile() { + return await fcb(cb => ( + writeFile(this.getEntryPath(), this.renderContent(), 'utf8', cb) + )); + } + + async clearBundleFile() { + try { + await fcb(cb => unlink(this.getOutputPath(), cb)); + } catch (e) { + return null; + } + } + + async isCacheValid() { + try { + await fcb(cb => stat(this.getOutputPath(), cb)); + return true; + } + catch (e) { + return false; + } + } + + toJSON() { + return { + id: this._id, + modules: this._modules, + entryPath: this.getEntryPath(), + outputPath: this.getOutputPath() + }; + } +} diff --git a/src/ui/ui_bundles/ui_bundles_controller.js b/src/ui/ui_bundles/ui_bundles_controller.js new file mode 100644 index 0000000000000..6d68c172b8f9b --- /dev/null +++ b/src/ui/ui_bundles/ui_bundles_controller.js @@ -0,0 +1,183 @@ +import { createHash } from 'crypto'; +import { resolve } from 'path'; + +import { UiBundle } from './ui_bundle'; +import { fromNode as fcb } from 'bluebird'; +import { makeRe } from 'minimatch'; +import mkdirp from 'mkdirp'; +import { appEntryTemplate } from './app_entry_template'; + +function getWebpackAliases(pluginSpecs) { + return pluginSpecs.reduce((aliases, spec) => { + const publicDir = spec.getPublicDir(); + + if (!publicDir) { + return aliases; + } + + return { + ...aliases, + [`plugins/${spec.getId()}`]: publicDir + }; + }, {}); +} + +export class UiBundlesController { + constructor(kbnServer) { + const { config, uiApps, uiExports, pluginSpecs } = kbnServer; + + this._workingDir = config.get('optimize.bundleDir'); + this._env = config.get('env.name'); + this._context = { + env: config.get('env.name'), + sourceMaps: config.get('optimize.sourceMaps'), + kbnVersion: config.get('pkg.version'), + buildNum: config.get('pkg.buildNum'), + plugins: pluginSpecs + .map(spec => spec.getId()) + .sort((a, b) => a.localeCompare(b)) + }; + + this._filter = makeRe(config.get('optimize.bundleFilter') || '*', { + noglobstar: true, + noext: true, + matchBase: true + }); + + this._webpackAliases = { + ...getWebpackAliases(pluginSpecs), + ...uiExports.webpackAliases + }; + this._webpackNoParseRules = uiExports.webpackNoParseRules; + this._postLoaders = []; + this._bundles = []; + + for (const uiApp of uiApps) { + this.add({ + id: uiApp.getId(), + modules: uiApp.getModules(), + template: appEntryTemplate, + }); + } + } + + add(bundleSpec) { + const { + id, + modules, + template, + } = bundleSpec; + + if (this._filter.test(id)) { + this._bundles.push(new UiBundle({ + id, + modules, + template, + controller: this, + })); + } + } + + getWebpackNoParseRules() { + return this._webpackNoParseRules; + } + + getWorkingDir() { + return this._workingDir; + } + + addPostLoader(loaderSpec) { + this._postLoaders.push(loaderSpec); + } + + getPostLoaders() { + return this._postLoaders; + } + + getAliases() { + return this._webpackAliases; + } + + isDevMode() { + return this._env === 'development'; + } + + getContext() { + return JSON.stringify(this._context, null, ' '); + } + + resolvePath(...args) { + return resolve(this._workingDir, ...args); + } + + getCachePath() { + return this.resolvePath('../.cache', this.hashBundleEntries()); + } + + getDescription() { + switch (this._bundles.length) { + case 0: + return '0 bundles'; + case 1: + return `bundle for ${this._bundles[0].id}`; + default: + const ids = this.getIds(); + const last = ids.pop(); + const commas = ids.join(', '); + return `bundles for ${commas} and ${last}`; + } + } + + async ensureDir() { + await fcb(cb => mkdirp(this._workingDir, cb)); + } + + async writeEntryFiles() { + await this.ensureDir(); + + for (const bundle of this._bundles) { + const existing = await bundle.readEntryFile(); + const expected = bundle.renderContent(); + + if (existing !== expected) { + await bundle.writeEntryFile(); + await bundle.clearBundleFile(); + } + } + } + + hashBundleEntries() { + const hash = createHash('sha1'); + for (const bundle of this._bundles) { + hash.update(`bundleEntryPath:${bundle.getEntryPath()}`); + hash.update(`bundleEntryContent:${bundle.renderContent()}`); + } + return hash.digest('hex'); + } + + async areAllBundleCachesValid() { + for (const bundle of this._bundles) { + if (!await bundle.isCacheValid()) { + return false; + } + } + + return true; + } + + toWebpackEntries() { + return this._bundles.reduce((entries, bundle) => ({ + ...entries, + [bundle.getId()]: bundle.getEntryPath(), + }), {}); + } + + getIds() { + return this._bundles + .map(bundle => bundle.getId()); + } + + toJSON() { + return this._bundles; + } +} diff --git a/src/ui/ui_bundles/ui_bundles_mixin.js b/src/ui/ui_bundles/ui_bundles_mixin.js new file mode 100644 index 0000000000000..9c39883c19dfe --- /dev/null +++ b/src/ui/ui_bundles/ui_bundles_mixin.js @@ -0,0 +1,10 @@ +import { UiBundlesController } from './ui_bundles_controller'; + +export async function uiBundlesMixin(kbnServer) { + kbnServer.uiBundles = new UiBundlesController(kbnServer); + + const { uiBundleProviders = [] } = kbnServer.uiExports; + for (const spec of uiBundleProviders) { + await spec(kbnServer); + } +} diff --git a/src/ui/ui_exports.js b/src/ui/ui_exports.js deleted file mode 100644 index 77ae99e2aa887..0000000000000 --- a/src/ui/ui_exports.js +++ /dev/null @@ -1,197 +0,0 @@ -import _ from 'lodash'; -import minimatch from 'minimatch'; - -import UiAppCollection from './ui_app_collection'; -import UiNavLinkCollection from './ui_nav_link_collection'; - -export default class UiExports { - constructor({ urlBasePath, kibanaIndexMappings }) { - this.navLinks = new UiNavLinkCollection(this); - this.apps = new UiAppCollection(this); - this.aliases = { - fieldFormatEditors: ['ui/field_format_editor/register'], - visRequestHandlers: [ - 'ui/vis/request_handlers/courier', - 'ui/vis/request_handlers/none' - ], - visResponseHandlers: [ - 'ui/vis/response_handlers/basic', - 'ui/vis/response_handlers/none', - 'ui/vis/response_handlers/tabify' - ], - visEditorTypes: [ - 'ui/vis/editors/default/default', - ], - embeddableFactories: [ - 'plugins/kibana/visualize/embeddable/visualize_embeddable_factory_provider', - 'plugins/kibana/discover/embeddable/search_embeddable_factory_provider', - ], - }; - this.urlBasePath = urlBasePath; - this.exportConsumer = _.memoize(this.exportConsumer); - this.consumers = []; - this.bundleProviders = []; - this.defaultInjectedVars = {}; - this.injectedVarsReplacers = []; - this.kibanaIndexMappings = kibanaIndexMappings; - } - - consumePlugin(plugin) { - plugin.apps = new UiAppCollection(this); - - const types = _.keys(plugin.uiExportsSpecs); - if (!types) return false; - - const unkown = _.reject(types, this.exportConsumer, this); - if (unkown.length) { - throw new Error('unknown export types ' + unkown.join(', ') + ' in plugin ' + plugin.id); - } - - for (const consumer of this.consumers) { - consumer.consumePlugin && consumer.consumePlugin(plugin); - } - - types.forEach((type) => { - this.exportConsumer(type)(plugin, plugin.uiExportsSpecs[type]); - }); - } - - addConsumer(consumer) { - this.consumers.push(consumer); - } - - addConsumerForType(typeToConsume, consumer) { - this.consumers.push({ - exportConsumer(uiExportType) { - if (uiExportType === typeToConsume) { - return consumer; - } - } - }); - } - - exportConsumer(type) { - for (const consumer of this.consumers) { - if (!consumer.exportConsumer) continue; - const fn = consumer.exportConsumer(type); - if (fn) return fn; - } - - switch (type) { - case 'app': - case 'apps': - return (plugin, specs) => { - for (const spec of [].concat(specs || [])) { - - const app = this.apps.new(_.defaults({}, spec, { - id: plugin.id, - urlBasePath: this.urlBasePath - })); - - plugin.extendInit((server, options) => { // eslint-disable-line no-loop-func - const wrapped = app.getInjectedVars; - app.getInjectedVars = () => wrapped.call(plugin, server, options); - }); - - plugin.apps.add(app); - } - }; - - case 'link': - case 'links': - return (plugin, specs) => { - for (const spec of [].concat(specs || [])) { - this.navLinks.new(spec); - } - }; - - case 'visTypes': - case 'visResponseHandlers': - case 'visRequestHandlers': - case 'visEditorTypes': - case 'savedObjectTypes': - case 'embeddableFactories': - case 'fieldFormats': - case 'fieldFormatEditors': - case 'spyModes': - case 'chromeNavControls': - case 'navbarExtensions': - case 'managementSections': - case 'devTools': - case 'docViews': - case 'home': - case 'hacks': - return (plugin, spec) => { - this.aliases[type] = _.union(this.aliases[type] || [], spec); - }; - - case 'visTypeEnhancers': - return (plugin, spec) => { - //used for plugins that augment capabilities of an existing visualization - this.aliases.visTypes = _.union(this.aliases.visTypes || [], spec); - }; - - case 'bundle': - return (plugin, spec) => { - this.bundleProviders.push(spec); - }; - - case 'aliases': - return (plugin, specs) => { - _.forOwn(specs, (spec, adhocType) => { - this.aliases[adhocType] = _.union(this.aliases[adhocType] || [], spec); - }); - }; - - case 'injectDefaultVars': - return (plugin, injector) => { - plugin.extendInit(async (server, options) => { - _.defaults(this.defaultInjectedVars, await injector.call(plugin, server, options)); - }); - }; - - case 'mappings': - return (plugin, mappings) => { - this.kibanaIndexMappings.addRootProperties(mappings, { plugin: plugin.id }); - }; - - case 'replaceInjectedVars': - return (plugin, replacer) => { - this.injectedVarsReplacers.push(replacer); - }; - } - } - - find(patterns) { - const aliases = this.aliases; - const names = _.keys(aliases); - const matcher = _.partialRight(minimatch.filter, { matchBase: true }); - - return _.chain(patterns) - .map(function (pattern) { - return names.filter(matcher(pattern)); - }) - .flattenDeep() - .reduce(function (found, name) { - return found.concat(aliases[name]); - }, []) - .value(); - } - - getAllApps() { - const { apps } = this; - return [...apps].concat(...apps.hidden); - } - - getApp(id) { - return this.apps.byId[id]; - } - - getHiddenApp(id) { - return this.apps.hidden.byId[id]; - } - - getBundleProviders() { - return this.bundleProviders; - } -} diff --git a/src/ui/ui_exports/README.md b/src/ui/ui_exports/README.md new file mode 100644 index 0000000000000..ab81febe67993 --- /dev/null +++ b/src/ui/ui_exports/README.md @@ -0,0 +1,96 @@ +# UI Exports + +When defining a Plugin, the `uiExports` key can be used to define a map of export types to values that will be used to configure the UI system. A common use for `uiExports` is `uiExports.app`, which defines the configuration of a [`UiApp`][UiApp] and teaches the UI System how to render, bundle and tell the user about an application. + + +## `collectUiExports(pluginSpecs): { [type: string]: any }` + +This function produces the object commonly found at `kbnServer.uiExports`. This object is created by calling `collectPluginExports()` with a standard set of export type reducers and defaults for the UI System. + +### export type reducers + +The [`ui_export_types` module][UiExportTypes] defines the reducer used for each uiExports key (or `type`). The name of every export in [./ui_export_types/index.js][UiExportTypes] is a key that plugins can define in their `uiExports` specification and the value of those exports are reducers that `collectPluginExports()` will call to produce the merged result of all export specs. + +### example - UiApps + +Plugin authors can define a new UiApp in their plugin specification like so: + +```js +// a single app export +export default function (kibana) { + return new kibana.Plugin({ + //... + uiExports: { + app: { + // uiApp spec options go here + } + } + }) +} + +// apps can also export multiple apps +export default function (kibana) { + return new kibana.Plugin({ + //... + uiExports: { + apps: [ + { /* uiApp spec options */ }, + { /* second uiApp spec options */ }, + ] + } + }) +} +``` + +To handle this export type, the [ui_export_types][UiExportTypes] module exports two reducers, one named `app` and the other `apps`. + +```js +export const app = ... +export const apps = ... +``` + +These reducers are defined in [`ui_export_types/ui_apps`][UiAppExportType] and have the exact same definition: + +```js +// `wrap()` produces a reducer by wrapping a base reducer with modifiers. +// All but the last argument are modifiers that take a reducer and return +// an alternate reducer to use in it's place. +// +// Most wrappers call their target reducer with slightly different +// arguments. This allows composing standard reducer modifications for +// reuse, consistency, and easy reference (once you get the hang of it). +wrap( + // calls the next reducer with the `type` set to `uiAppSpecs`, ignoring + // the key the plugin author used to define this spec ("app" or "apps" + // in this example) + alias('uiAppSpecs'), + + // calls the next reducer with the `spec` set to the result of calling + // `applySpecDefaults(spec, type, pluginSpec)` which merges some defaults + // from the `PluginSpec` because we want uiAppSpecs to be useful individually + mapSpec(applySpecDefaults), + + // writes this spec to `acc[type]` (`acc.uiAppSpecs` in this example since + // the type was set to `uiAppSpecs` by `alias()`). It does this by concatenating + // the current value and the spec into an array. If either item is already + // an array its items are added to the result individually. If either item + // is undefined it is ignored. + // + // NOTE: since flatConcatAtType is last it isn't a wrapper, it's + // just a normal reducer + flatConcatAtType +) +``` + +This reducer format was chosen so that it will be easier to look back at these reducers and see that `app` and `apps` export specs are written to `kbnServer.uiExports.uiAppSpecs`, with defaults applied, in an array. + +### defaults + +The [`ui_exports/ui_export_defaults`][UiExportDefaults] module defines the default shape of the uiExports object produced by `collectUiExports()`. The defaults generally describe the `uiExports` from the UI System itself, like default visTypes and such. + +[UiApp]: ../ui_apps/ui_app.js "UiApp class definition" +[UiExportDefaults]: ./ui_export_defaults.js "uiExport defaults definition" +[UiExportTypes]: ./ui_export_types/index.js "Index of default ui_export_types module" +[UiAppExportType]: ./ui_export_types/ui_apps.js "UiApp extension type definition" +[PluginSpec]: ../../plugin_discovery/plugin_spec/plugin_spec.js "PluginSpec class definition" +[PluginDiscovery]: '../../plugin_discovery' "plugin_discovery module" \ No newline at end of file diff --git a/src/ui/ui_exports/__tests__/collect_ui_exports.js b/src/ui/ui_exports/__tests__/collect_ui_exports.js new file mode 100644 index 0000000000000..9609a4a95c12b --- /dev/null +++ b/src/ui/ui_exports/__tests__/collect_ui_exports.js @@ -0,0 +1,56 @@ +import expect from 'expect.js'; + +import { PluginPack } from '../../../plugin_discovery'; + +import { collectUiExports } from '../collect_ui_exports'; + +const specs = new PluginPack({ + path: '/dev/null', + pkg: { + name: 'test', + version: 'kibana' + }, + provider({ Plugin }) { + return [ + new Plugin({ + id: 'test', + uiExports: { + visTypes: [ + 'plugin/test/visType1', + 'plugin/test/visType2', + 'plugin/test/visType3', + ] + } + }), + new Plugin({ + id: 'test2', + uiExports: { + visTypes: [ + 'plugin/test2/visType1', + 'plugin/test2/visType2', + 'plugin/test2/visType3', + ] + } + }), + ]; + } +}).getPluginSpecs(); + +describe('plugin discovery', () => { + describe('collectUiExports()', () => { + it('merges uiExports from all provided plugin specs', () => { + const uiExports = collectUiExports(specs); + const exported = uiExports.appExtensions.visTypes + .sort((a, b) => a.localeCompare(b)); + + expect(exported).to.eql([ + 'plugin/test/visType1', + 'plugin/test/visType2', + 'plugin/test/visType3', + 'plugin/test2/visType1', + 'plugin/test2/visType2', + 'plugin/test2/visType3' + ]); + }); + }); +}); diff --git a/src/ui/ui_exports/collect_ui_exports.js b/src/ui/ui_exports/collect_ui_exports.js new file mode 100644 index 0000000000000..2bc3efe400b67 --- /dev/null +++ b/src/ui/ui_exports/collect_ui_exports.js @@ -0,0 +1,11 @@ +import { UI_EXPORT_DEFAULTS } from './ui_export_defaults'; +import * as uiExportTypeReducers from './ui_export_types'; +import { reduceExportSpecs } from '../../plugin_discovery'; + +export function collectUiExports(pluginSpecs) { + return reduceExportSpecs( + pluginSpecs, + uiExportTypeReducers, + UI_EXPORT_DEFAULTS + ); +} diff --git a/src/ui/ui_exports/index.js b/src/ui/ui_exports/index.js new file mode 100644 index 0000000000000..a9a1e989fbe72 --- /dev/null +++ b/src/ui/ui_exports/index.js @@ -0,0 +1,2 @@ +export { collectUiExports } from './collect_ui_exports'; +export { uiExportsMixin } from './ui_exports_mixin'; diff --git a/src/ui/ui_exports/ui_export_defaults.js b/src/ui/ui_exports/ui_export_defaults.js new file mode 100644 index 0000000000000..bd96d12fccd67 --- /dev/null +++ b/src/ui/ui_exports/ui_export_defaults.js @@ -0,0 +1,45 @@ +import { dirname, resolve } from 'path'; +const ROOT = dirname(require.resolve('../../../package.json')); + +export const UI_EXPORT_DEFAULTS = { + webpackNoParseRules: [ + /node_modules[\/\\](angular|elasticsearch-browser)[\/\\]/, + /node_modules[\/\\](mocha|moment)[\/\\]/ + ], + + webpackAliases: { + ui: resolve(ROOT, 'src/ui/public'), + ui_framework: resolve(ROOT, 'ui_framework'), + packages: resolve(ROOT, 'packages'), + test_harness: resolve(ROOT, 'src/test_harness/public'), + querystring: 'querystring-browser', + moment$: resolve(ROOT, 'webpackShims/moment'), + 'moment-timezone$': resolve(ROOT, 'webpackShims/moment-timezone') + }, + + translationPaths: [ + resolve(ROOT, 'src/ui/ui_i18n/translations/en.json'), + ], + + appExtensions: { + fieldFormatEditors: [ + 'ui/field_format_editor/register' + ], + visRequestHandlers: [ + 'ui/vis/request_handlers/courier', + 'ui/vis/request_handlers/none' + ], + visResponseHandlers: [ + 'ui/vis/response_handlers/basic', + 'ui/vis/response_handlers/none', + 'ui/vis/response_handlers/tabify', + ], + visEditorTypes: [ + 'ui/vis/editors/default/default', + ], + embeddableFactories: [ + 'plugins/kibana/visualize/embeddable/visualize_embeddable_factory_provider', + 'plugins/kibana/discover/embeddable/search_embeddable_factory_provider', + ] + }, +}; diff --git a/src/ui/ui_exports/ui_export_types/index.js b/src/ui/ui_exports/ui_export_types/index.js new file mode 100644 index 0000000000000..55a35d54c3795 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/index.js @@ -0,0 +1,57 @@ +export { + injectDefaultVars, + replaceInjectedVars, +} from './modify_injected_vars'; + +export { + mappings, +} from './saved_object_mappings'; + +export { + app, + apps, +} from './ui_apps'; + +export { + visTypes, + visResponseHandlers, + visRequestHandlers, + visEditorTypes, + savedObjectTypes, + embeddableFactories, + fieldFormats, + fieldFormatEditors, + spyModes, + chromeNavControls, + navbarExtensions, + managementSections, + devTools, + docViews, + hacks, + home, + visTypeEnhancers, + aliases, +} from './ui_app_extensions'; + +export { + translations, +} from './ui_i18n'; + +export { + link, + links, +} from './ui_nav_links'; + +export { + uiSettingDefaults, +} from './ui_settings'; + +export { + unknown, +} from './unknown'; + +export { + noParse, + __globalImportAliases__, + __bundleProvider__, +} from './webpack_customizations'; diff --git a/src/ui/ui_exports/ui_export_types/modify_injected_vars.js b/src/ui/ui_exports/ui_export_types/modify_injected_vars.js new file mode 100644 index 0000000000000..2a26bc98aa4d3 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/modify_injected_vars.js @@ -0,0 +1,13 @@ +import { flatConcatAtType } from './reduce'; +import { wrap, alias, mapSpec } from './modify_reduce'; + +export const replaceInjectedVars = wrap(alias('injectedVarsReplacers'), flatConcatAtType); + +export const injectDefaultVars = wrap( + alias('defaultInjectedVarProviders'), + mapSpec((spec, type, pluginSpec) => ({ + pluginSpec, + fn: spec, + })), + flatConcatAtType +); diff --git a/src/ui/ui_exports/ui_export_types/modify_reduce/alias.js b/src/ui/ui_exports/ui_export_types/modify_reduce/alias.js new file mode 100644 index 0000000000000..8a13fa7a12ae5 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/modify_reduce/alias.js @@ -0,0 +1,10 @@ +/** + * Creates a reducer wrapper which, when called with a reducer, creates a new + * reducer that replaces the `type` value with `newType` before delegating to + * the wrapped reducer + * @param {String} newType + * @return {Function} + */ +export const alias = (newType) => (next) => (acc, spec, type, pluginSpec) => ( + next(acc, spec, newType, pluginSpec) +); diff --git a/src/ui/ui_exports/ui_export_types/modify_reduce/debug.js b/src/ui/ui_exports/ui_export_types/modify_reduce/debug.js new file mode 100644 index 0000000000000..5c2df3059fae2 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/modify_reduce/debug.js @@ -0,0 +1,12 @@ +import { mapSpec } from './map_spec'; + +/** + * Reducer wrapper which, replaces the `spec` with the details about the definition + * of that spec + * @type {Function} + */ +export const debug = mapSpec((spec, type, pluginSpec) => ({ + spec, + type, + pluginSpec +})); diff --git a/src/ui/ui_exports/ui_export_types/modify_reduce/index.js b/src/ui/ui_exports/ui_export_types/modify_reduce/index.js new file mode 100644 index 0000000000000..9c698f304ec6e --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/modify_reduce/index.js @@ -0,0 +1,5 @@ +export { alias } from './alias'; +export { debug } from './debug'; +export { mapSpec } from './map_spec'; +export { wrap } from './wrap'; +export { uniqueKeys } from './unique_keys'; diff --git a/src/ui/ui_exports/ui_export_types/modify_reduce/map_spec.js b/src/ui/ui_exports/ui_export_types/modify_reduce/map_spec.js new file mode 100644 index 0000000000000..3607e8403c4f5 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/modify_reduce/map_spec.js @@ -0,0 +1,11 @@ +/** + * Creates a reducer wrapper which, when called with a reducer, creates a new + * reducer that replaces the `specs` value with the result of calling + * `mapFn(spec, type, pluginSpec)` before delegating to the wrapped + * reducer + * @param {Function} mapFn receives `(specs, type, pluginSpec)` + * @return {Function} + */ +export const mapSpec = (mapFn) => (next) => (acc, spec, type, pluginSpec) => ( + next(acc, mapFn(spec, type, pluginSpec), type, pluginSpec) +); diff --git a/src/ui/ui_exports/ui_export_types/modify_reduce/unique_keys.js b/src/ui/ui_exports/ui_export_types/modify_reduce/unique_keys.js new file mode 100644 index 0000000000000..4d7f3650710f2 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/modify_reduce/unique_keys.js @@ -0,0 +1,10 @@ +export const uniqueKeys = (sourceType) => (next) => (acc, spec, type, pluginSpec) => { + const duplicates = Object.keys(spec) + .filter(key => acc[type] && acc[type].hasOwnProperty(key)); + + if (duplicates.length) { + throw new Error(`${pluginSpec.id()} defined duplicate ${sourceType || type} values: ${duplicates}`); + } + + return next(acc, spec, type, pluginSpec); +}; diff --git a/src/ui/ui_exports/ui_export_types/modify_reduce/wrap.js b/src/ui/ui_exports/ui_export_types/modify_reduce/wrap.js new file mode 100644 index 0000000000000..55a940192bf58 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/modify_reduce/wrap.js @@ -0,0 +1,28 @@ +/** + * Wrap a function with any number of wrappers. Wrappers + * are functions that take a reducer and return a reducer + * that should be called in its place. The wrappers will + * be called in reverse order for setup and then in the + * order they are defined when the resulting reducer is + * executed. + * + * const reducer = wrap( + * next => (acc) => acc[1] = 'a', + * next => (acc) => acc[1] = 'b', + * next => (acc) => acc[1] = 'c' + * ) + * + * reducer('foo') //=> 'fco' + * + * @param {Function} ...wrappers + * @param {Function} reducer + * @return {Function} + */ +export function wrap(...args) { + const reducer = args[args.length - 1]; + const wrappers = args.slice(0, -1); + + return wrappers + .reverse() + .reduce((acc, wrapper) => wrapper(acc), reducer); +} diff --git a/src/ui/ui_exports/ui_export_types/reduce/flat_concat_at_type.js b/src/ui/ui_exports/ui_export_types/reduce/flat_concat_at_type.js new file mode 100644 index 0000000000000..b141f2fd605ce --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/reduce/flat_concat_at_type.js @@ -0,0 +1,9 @@ +import { createTypeReducer, flatConcat } from './lib'; + +/** + * Reducer that merges two values concatenating all values + * into a flattened array + * @param {Any} [initial] + * @return {Function} + */ +export const flatConcatAtType = createTypeReducer(flatConcat); diff --git a/src/ui/ui_exports/ui_export_types/reduce/flat_concat_values_at_type.js b/src/ui/ui_exports/ui_export_types/reduce/flat_concat_values_at_type.js new file mode 100644 index 0000000000000..085c286750647 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/reduce/flat_concat_values_at_type.js @@ -0,0 +1,15 @@ +import { + createTypeReducer, + flatConcat, + mergeWith, +} from './lib'; + +/** + * Reducer that merges specs by concatenating the values of + * all keys in accumulator and spec with the same logic as concat + * @param {[type]} initial [description] + * @return {[type]} [description] + */ +export const flatConcatValuesAtType = createTypeReducer((objectA, objectB) => ( + mergeWith(objectA || {}, objectB || {}, flatConcat) +)); diff --git a/src/ui/ui_exports/ui_export_types/reduce/index.js b/src/ui/ui_exports/ui_export_types/reduce/index.js new file mode 100644 index 0000000000000..e131fe1c261d6 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/reduce/index.js @@ -0,0 +1,3 @@ +export { mergeAtType } from './merge_at_type'; +export { flatConcatValuesAtType } from './flat_concat_values_at_type'; +export { flatConcatAtType } from './flat_concat_at_type'; diff --git a/src/ui/ui_exports/ui_export_types/reduce/lib/create_type_reducer.js b/src/ui/ui_exports/ui_export_types/reduce/lib/create_type_reducer.js new file mode 100644 index 0000000000000..c5ff343554e27 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/reduce/lib/create_type_reducer.js @@ -0,0 +1,13 @@ +/** + * Creates a reducer that reduces the values within `acc[type]` by calling + * reducer with signature: + * + * reducer(acc[type], spec, type, pluginSpec) + * + * @param {Function} reducer + * @return {Function} + */ +export const createTypeReducer = (reducer) => (acc, spec, type, pluginSpec) => ({ + ...acc, + [type]: reducer(acc[type], spec, type, pluginSpec) +}); diff --git a/src/ui/ui_exports/ui_export_types/reduce/lib/flat_concat.js b/src/ui/ui_exports/ui_export_types/reduce/lib/flat_concat.js new file mode 100644 index 0000000000000..a61c240836d02 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/reduce/lib/flat_concat.js @@ -0,0 +1,13 @@ +/** + * Concatenate two values into a single array, ignoring either + * value if it is undefined and flattening the value if it is an array + * @param {Array|T} a + * @param {Array} b + * @return {Array} + */ +export const flatConcat = (a, b) => ( + [].concat( + a === undefined ? [] : a, + b === undefined ? [] : b + ) +); diff --git a/src/ui/ui_exports/ui_export_types/reduce/lib/index.js b/src/ui/ui_exports/ui_export_types/reduce/lib/index.js new file mode 100644 index 0000000000000..79e42849a3f3e --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/reduce/lib/index.js @@ -0,0 +1,3 @@ +export { flatConcat } from './flat_concat'; +export { mergeWith } from './merge_with'; +export { createTypeReducer } from './create_type_reducer'; diff --git a/src/ui/ui_exports/ui_export_types/reduce/lib/merge_with.js b/src/ui/ui_exports/ui_export_types/reduce/lib/merge_with.js new file mode 100644 index 0000000000000..edeab703f70c5 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/reduce/lib/merge_with.js @@ -0,0 +1,22 @@ +const uniqueConcat = (arrayA, arrayB) => arrayB.reduce((acc, key) => ( + acc.includes(key) + ? acc + : acc.concat(key) +), arrayA); + +/** + * Assign the keys from both objA and objB to target after passing the + * current and new value through merge as `(target[key], source[key])` + * @param {Object} objA + * @param {Object} objB + * @param {Function} merge + * @return {Object} target + */ +export function mergeWith(objA, objB, merge) { + const target = {}; + const keys = uniqueConcat(Object.keys(objA), Object.keys(objB)); + for (const key of keys) { + target[key] = merge(objA[key], objB[key]); + } + return target; +} diff --git a/src/ui/ui_exports/ui_export_types/reduce/merge_at_type.js b/src/ui/ui_exports/ui_export_types/reduce/merge_at_type.js new file mode 100644 index 0000000000000..e2b79837c25f3 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/reduce/merge_at_type.js @@ -0,0 +1,6 @@ +import { createTypeReducer } from './lib'; + +export const mergeAtType = createTypeReducer((a, b) => ({ + ...a, + ...b +})); diff --git a/src/ui/ui_exports/ui_export_types/saved_object_mappings.js b/src/ui/ui_exports/ui_export_types/saved_object_mappings.js new file mode 100644 index 0000000000000..118e08824fd95 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/saved_object_mappings.js @@ -0,0 +1,12 @@ +import { flatConcatAtType } from './reduce'; +import { alias, mapSpec, wrap } from './modify_reduce'; + +// mapping types +export const mappings = wrap( + alias('savedObjectMappings'), + mapSpec((spec, type, pluginSpec) => ({ + pluginId: pluginSpec.getId(), + properties: spec + })), + flatConcatAtType +); diff --git a/src/ui/ui_exports/ui_export_types/ui_app_extensions.js b/src/ui/ui_exports/ui_export_types/ui_app_extensions.js new file mode 100644 index 0000000000000..edfa103e264e8 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/ui_app_extensions.js @@ -0,0 +1,39 @@ +import { flatConcatValuesAtType } from './reduce'; +import { mapSpec, alias, wrap } from './modify_reduce'; + +/** + * Reducer "preset" that merges named "first-class" appExtensions by + * converting them into objects and then concatenating the values of those objects + * @type {Function} + */ +const appExtension = wrap( + mapSpec((spec, type) => ({ [type]: spec })), + alias('appExtensions'), + flatConcatValuesAtType +); + +// plain extension groups produce lists of modules that will be required by the entry +// files to include extensions of specific types into specific apps +export const visTypes = appExtension; +export const visResponseHandlers = appExtension; +export const visRequestHandlers = appExtension; +export const visEditorTypes = appExtension; +export const savedObjectTypes = appExtension; +export const embeddableFactories = appExtension; +export const fieldFormats = appExtension; +export const fieldFormatEditors = appExtension; +export const spyModes = appExtension; +export const chromeNavControls = appExtension; +export const navbarExtensions = appExtension; +export const managementSections = appExtension; +export const devTools = appExtension; +export const docViews = appExtension; +export const hacks = appExtension; +export const home = appExtension; + +// aliases visTypeEnhancers to the visTypes group +export const visTypeEnhancers = wrap(alias('visTypes'), appExtension); + +// adhoc extension groups can define new extension groups on the fly +// so that plugins could concat their own +export const aliases = flatConcatValuesAtType; diff --git a/src/ui/ui_exports/ui_export_types/ui_apps.js b/src/ui/ui_exports/ui_export_types/ui_apps.js new file mode 100644 index 0000000000000..5e801c675f601 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/ui_apps.js @@ -0,0 +1,47 @@ +import { noop, uniq } from 'lodash'; + +import { flatConcatAtType } from './reduce'; +import { alias, mapSpec, wrap } from './modify_reduce'; + +function applySpecDefaults(spec, type, pluginSpec) { + const pluginId = pluginSpec.getId(); + const { + id = pluginId, + main, + title, + order = 0, + description = '', + icon, + hidden = false, + linkToLastSubUrl = true, + listed = !hidden, + templateName = 'ui_app', + injectVars = noop, + url = `/app/${id}`, + uses = [], + } = spec; + + return { + pluginId, + id, + main, + title, + order, + description, + icon, + hidden, + linkToLastSubUrl, + listed, + templateName, + injectVars, + url, + uses: uniq([ + ...uses, + 'chromeNavControls', + 'hacks', + ]), + }; +} + +export const apps = wrap(alias('uiAppSpecs'), mapSpec(applySpecDefaults), flatConcatAtType); +export const app = wrap(alias('uiAppSpecs'), mapSpec(applySpecDefaults), flatConcatAtType); diff --git a/src/ui/ui_exports/ui_export_types/ui_i18n.js b/src/ui/ui_exports/ui_export_types/ui_i18n.js new file mode 100644 index 0000000000000..c734562f7737d --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/ui_i18n.js @@ -0,0 +1,5 @@ +import { flatConcatAtType } from './reduce'; +import { wrap, alias } from './modify_reduce'; + +// paths to translation files +export const translations = wrap(alias('translationPaths'), flatConcatAtType); diff --git a/src/ui/ui_exports/ui_export_types/ui_nav_links.js b/src/ui/ui_exports/ui_export_types/ui_nav_links.js new file mode 100644 index 0000000000000..e17a1970c562e --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/ui_nav_links.js @@ -0,0 +1,5 @@ +import { flatConcatAtType } from './reduce'; +import { wrap, alias } from './modify_reduce'; + +export const links = wrap(alias('navLinkSpecs'), flatConcatAtType); +export const link = wrap(alias('navLinkSpecs'), flatConcatAtType); diff --git a/src/ui/ui_exports/ui_export_types/ui_settings.js b/src/ui/ui_exports/ui_export_types/ui_settings.js new file mode 100644 index 0000000000000..024c53682a7a7 --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/ui_settings.js @@ -0,0 +1,4 @@ +import { mergeAtType } from './reduce'; +import { wrap, uniqueKeys } from './modify_reduce'; + +export const uiSettingDefaults = wrap(uniqueKeys(), mergeAtType); diff --git a/src/ui/ui_exports/ui_export_types/unknown.js b/src/ui/ui_exports/ui_export_types/unknown.js new file mode 100644 index 0000000000000..27b3430428d2a --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/unknown.js @@ -0,0 +1,4 @@ +import { flatConcatAtType } from './reduce'; +import { wrap, alias, debug } from './modify_reduce'; + +export const unknown = wrap(debug, alias('unknown'), flatConcatAtType); diff --git a/src/ui/ui_exports/ui_export_types/webpack_customizations.js b/src/ui/ui_exports/ui_export_types/webpack_customizations.js new file mode 100644 index 0000000000000..4b5966efa8ebc --- /dev/null +++ b/src/ui/ui_exports/ui_export_types/webpack_customizations.js @@ -0,0 +1,24 @@ +import { isAbsolute } from 'path'; + +import { escapeRegExp } from 'lodash'; + +import { flatConcatAtType, mergeAtType } from './reduce'; +import { alias, wrap, uniqueKeys, mapSpec } from './modify_reduce'; + +export const __globalImportAliases__ = wrap(alias('webpackAliases'), uniqueKeys('__globalImportAliases__'), mergeAtType); +export const __bundleProvider__ = wrap(alias('uiBundleProviders'), flatConcatAtType); +export const noParse = wrap( + alias('webpackNoParseRules'), + mapSpec(rule => { + if (typeof rule === 'string') { + return new RegExp(`${isAbsolute(rule) ? '^' : ''}${escapeRegExp(rule)}`); + } + + if (rule instanceof RegExp) { + return rule; + } + + throw new Error('Expected noParse rule to be a string or regexp'); + }), + flatConcatAtType +); diff --git a/src/ui/ui_exports/ui_exports_mixin.js b/src/ui/ui_exports/ui_exports_mixin.js new file mode 100644 index 0000000000000..d36c8f96016f2 --- /dev/null +++ b/src/ui/ui_exports/ui_exports_mixin.js @@ -0,0 +1,19 @@ +import { collectUiExports } from './collect_ui_exports'; + +export function uiExportsMixin(kbnServer) { + kbnServer.uiExports = collectUiExports( + kbnServer.pluginSpecs + ); + + // check for unknown uiExport types + const { unknown = [] } = kbnServer.uiExports; + if (!unknown.length) { + return; + } + + throw new Error(`Unknown uiExport types: ${ + unknown + .map(({ pluginSpec, type }) => `${type} from ${pluginSpec.getId()}`) + .join(', ') + }`); +} diff --git a/src/ui/ui_i18n.js b/src/ui/ui_i18n.js deleted file mode 100644 index 5837d4f92975e..0000000000000 --- a/src/ui/ui_i18n.js +++ /dev/null @@ -1,66 +0,0 @@ -import { resolve } from 'path'; - -import { defaults, compact } from 'lodash'; -import langParser from 'accept-language-parser'; - -import { I18n } from './i18n'; - -function acceptLanguageHeaderToBCP47Tags(header) { - return langParser.parse(header).map(lang => ( - compact([lang.code, lang.region, lang.script]).join('-') - )); -} - -export class UiI18n { - constructor(defaultLocale = 'en') { - this._i18n = new I18n(defaultLocale); - this._i18n.registerTranslations(resolve(__dirname, './translations/en.json')); - } - - /** - * Fetch the language translations as defined by the request. - * - * @param {Hapi.Request} request - * @returns {Promise} translations promise for an object where - * keys are translation keys and - * values are translations - */ - async getTranslationsForRequest(request) { - const header = request.headers['accept-language']; - const tags = acceptLanguageHeaderToBCP47Tags(header); - const requestedTranslations = await this._i18n.getTranslations(...tags); - const defaultTranslations = await this._i18n.getTranslationsForDefaultLocale(); - return defaults({}, requestedTranslations, defaultTranslations); - } - - /** - * uiExport consumers help the uiExports module know what to - * do with the uiExports defined by each plugin. - * - * This consumer will allow plugins to define export with the - * "translations" type like so: - * - * new kibana.Plugin({ - * uiExports: { - * translations: [ - * resolve(__dirname, './translations/es.json'), - * ], - * }, - * }); - * - */ - addUiExportConsumer(uiExports) { - uiExports.addConsumerForType('translations', (plugin, translations) => { - translations.forEach(path => { - this._i18n.registerTranslations(path); - }); - }); - } - - /** - Refer to I18n.getAllTranslations() - */ - getAllTranslations() { - return this._i18n.getAllTranslations(); - } -} diff --git a/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/de.json b/src/ui/ui_i18n/__tests__/fixtures/translations/test_plugin_1/de.json similarity index 100% rename from src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/de.json rename to src/ui/ui_i18n/__tests__/fixtures/translations/test_plugin_1/de.json diff --git a/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/en.json b/src/ui/ui_i18n/__tests__/fixtures/translations/test_plugin_1/en.json similarity index 100% rename from src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/en.json rename to src/ui/ui_i18n/__tests__/fixtures/translations/test_plugin_1/en.json diff --git a/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/es-ES.json b/src/ui/ui_i18n/__tests__/fixtures/translations/test_plugin_1/es-ES.json similarity index 100% rename from src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/es-ES.json rename to src/ui/ui_i18n/__tests__/fixtures/translations/test_plugin_1/es-ES.json diff --git a/src/ui/i18n/__tests__/fixtures/translations/test_plugin_2/de.json b/src/ui/ui_i18n/__tests__/fixtures/translations/test_plugin_2/de.json similarity index 100% rename from src/ui/i18n/__tests__/fixtures/translations/test_plugin_2/de.json rename to src/ui/ui_i18n/__tests__/fixtures/translations/test_plugin_2/de.json diff --git a/src/ui/i18n/__tests__/fixtures/translations/test_plugin_2/en.json b/src/ui/ui_i18n/__tests__/fixtures/translations/test_plugin_2/en.json similarity index 100% rename from src/ui/i18n/__tests__/fixtures/translations/test_plugin_2/en.json rename to src/ui/ui_i18n/__tests__/fixtures/translations/test_plugin_2/en.json diff --git a/src/ui/i18n/__tests__/i18n.js b/src/ui/ui_i18n/__tests__/i18n.js similarity index 100% rename from src/ui/i18n/__tests__/i18n.js rename to src/ui/ui_i18n/__tests__/i18n.js diff --git a/src/ui/i18n/i18n.js b/src/ui/ui_i18n/i18n.js similarity index 96% rename from src/ui/i18n/i18n.js rename to src/ui/ui_i18n/i18n.js index 8bbe20483a9e0..f9c495790a8eb 100644 --- a/src/ui/i18n/i18n.js +++ b/src/ui/ui_i18n/i18n.js @@ -30,6 +30,15 @@ function getBestLocaleMatch(languageTag, registeredLocales) { } export class I18n { + static async getAllTranslationsFromPaths(paths) { + const i18n = new I18n(); + + paths.forEach(path => { + i18n.registerTranslations(path); + }); + + return await i18n.getAllTranslations(); + } _registeredTranslations = {}; diff --git a/src/ui/ui_i18n/index.js b/src/ui/ui_i18n/index.js new file mode 100644 index 0000000000000..166b45aa99ae0 --- /dev/null +++ b/src/ui/ui_i18n/index.js @@ -0,0 +1,2 @@ +export { I18n } from './i18n'; +export { uiI18nMixin } from './ui_i18n_mixin'; diff --git a/src/ui/ui_i18n/translations/en.json b/src/ui/ui_i18n/translations/en.json new file mode 100644 index 0000000000000..ac491cf6f3465 --- /dev/null +++ b/src/ui/ui_i18n/translations/en.json @@ -0,0 +1,4 @@ +{ + "UI-WELCOME_MESSAGE": "Loading", + "UI-WELCOME_ERROR": "" +} diff --git a/src/ui/ui_i18n/ui_i18n_mixin.js b/src/ui/ui_i18n/ui_i18n_mixin.js new file mode 100644 index 0000000000000..c11b4c765a494 --- /dev/null +++ b/src/ui/ui_i18n/ui_i18n_mixin.js @@ -0,0 +1,49 @@ +import { defaults, compact } from 'lodash'; +import langParser from 'accept-language-parser'; + +import { I18n } from './i18n'; + +function acceptLanguageHeaderToBCP47Tags(header) { + return langParser.parse(header).map(lang => ( + compact([lang.code, lang.region, lang.script]).join('-') + )); +} + +export function uiI18nMixin(kbnServer, server, config) { + const defaultLocale = config.get('i18n.defaultLocale'); + + const i18n = new I18n(defaultLocale); + const { translationPaths = [] } = kbnServer.uiExports; + translationPaths.forEach(translationPath => { + i18n.registerTranslations(translationPath); + }); + + /** + * Fetch the translations matching the Accept-Language header for a requests. + * @name request.getUiTranslations + * @returns {Promise>} translations + */ + server.decorate('request', 'getUiTranslations', async function () { + const header = this.headers['accept-language']; + const tags = acceptLanguageHeaderToBCP47Tags(header); + + const requestedTranslations = await i18n.getTranslations(...tags); + const defaultTranslations = await i18n.getTranslationsForDefaultLocale(); + + return defaults( + {}, + requestedTranslations, + defaultTranslations + ); + }); + + /** + * Return all translations for registered locales + * @name server.getAllUiTranslations + * @return {Promise>>} + */ + server.decorate('server', 'getAllUiTranslations', async () => { + return await i18n.getAllTranslations(); + }); + +} diff --git a/src/ui/ui_mixin.js b/src/ui/ui_mixin.js new file mode 100644 index 0000000000000..0c94fc457c5b6 --- /dev/null +++ b/src/ui/ui_mixin.js @@ -0,0 +1,19 @@ +import { uiExportsMixin } from './ui_exports'; +import { fieldFormatsMixin } from './field_formats'; +import { uiAppsMixin } from './ui_apps'; +import { uiI18nMixin } from './ui_i18n'; +import { uiBundlesMixin } from './ui_bundles'; +import { uiNavLinksMixin } from './ui_nav_links'; +import { uiRenderMixin } from './ui_render'; +import { uiSettingsMixin } from './ui_settings'; + +export async function uiMixin(kbnServer) { + await kbnServer.mixin(uiExportsMixin); + await kbnServer.mixin(uiAppsMixin); + await kbnServer.mixin(uiBundlesMixin); + await kbnServer.mixin(uiSettingsMixin); + await kbnServer.mixin(fieldFormatsMixin); + await kbnServer.mixin(uiNavLinksMixin); + await kbnServer.mixin(uiI18nMixin); + await kbnServer.mixin(uiRenderMixin); +} diff --git a/src/ui/ui_nav_link.js b/src/ui/ui_nav_link.js deleted file mode 100644 index 409df41d2dc6c..0000000000000 --- a/src/ui/ui_nav_link.js +++ /dev/null @@ -1,15 +0,0 @@ -export default class UiNavLink { - constructor(uiExports, spec) { - this.id = spec.id; - this.title = spec.title; - this.order = spec.order || 0; - this.url = `${uiExports.urlBasePath || ''}${spec.url}`; - this.subUrlBase = `${uiExports.urlBasePath || ''}${spec.subUrlBase || spec.url}`; - this.description = spec.description; - this.icon = spec.icon; - this.linkToLastSubUrl = spec.linkToLastSubUrl === false ? false : true; - this.hidden = spec.hidden || false; - this.disabled = spec.disabled || false; - this.tooltip = spec.tooltip || ''; - } -} diff --git a/src/ui/ui_nav_link_collection.js b/src/ui/ui_nav_link_collection.js deleted file mode 100644 index 8e0b274a439b3..0000000000000 --- a/src/ui/ui_nav_link_collection.js +++ /dev/null @@ -1,34 +0,0 @@ -import { sortBy } from 'lodash'; -import UiNavLink from './ui_nav_link'; -import Collection from '../utils/collection'; - -const inOrderCache = Symbol('inOrder'); - -export default class UiNavLinkCollection extends Collection { - - constructor(uiExports) { - super(); - this.uiExports = uiExports; - } - - new(spec) { - const link = new UiNavLink(this.uiExports, spec); - this[inOrderCache] = null; - this.add(link); - return link; - } - - get inOrder() { - if (!this[inOrderCache]) { - this[inOrderCache] = sortBy([...this], 'order'); - } - - return this[inOrderCache]; - } - - delete(value) { - this[inOrderCache] = null; - return super.delete(value); - } - -} diff --git a/src/ui/ui_nav_links/__tests__/ui_nav_link.js b/src/ui/ui_nav_links/__tests__/ui_nav_link.js new file mode 100644 index 0000000000000..ee3a8c6c977c9 --- /dev/null +++ b/src/ui/ui_nav_links/__tests__/ui_nav_link.js @@ -0,0 +1,142 @@ +import expect from 'expect.js'; + +import { UiNavLink } from '../ui_nav_link'; + +describe('UiNavLink', () => { + describe('constructor', () => { + it('initializes the object properties as expected', () => { + const urlBasePath = 'http://localhost:5601/rnd'; + const spec = { + id: 'kibana:discover', + title: 'Discover', + order: -1003, + url: '/app/kibana#/discover', + description: 'interactively explore your data', + icon: 'plugins/kibana/assets/discover.svg', + hidden: true, + disabled: true + }; + + const link = new UiNavLink(urlBasePath, spec); + expect(link.toJSON()).to.eql({ + id: spec.id, + title: spec.title, + order: spec.order, + url: `${urlBasePath}${spec.url}`, + subUrlBase: `${urlBasePath}${spec.url}`, + description: spec.description, + icon: spec.icon, + hidden: spec.hidden, + disabled: spec.disabled, + + // defaults + linkToLastSubUrl: true, + tooltip: '' + }); + }); + + it('initializes the url property without a base path when one is not specified in the spec', () => { + const urlBasePath = undefined; + const spec = { + id: 'kibana:discover', + title: 'Discover', + order: -1003, + url: '/app/kibana#/discover', + description: 'interactively explore your data', + icon: 'plugins/kibana/assets/discover.svg', + }; + const link = new UiNavLink(urlBasePath, spec); + expect(link.toJSON()).to.have.property('url', spec.url); + }); + + it('initializes the order property to 0 when order is not specified in the spec', () => { + const urlBasePath = undefined; + const spec = { + id: 'kibana:discover', + title: 'Discover', + url: '/app/kibana#/discover', + description: 'interactively explore your data', + icon: 'plugins/kibana/assets/discover.svg', + }; + const link = new UiNavLink(urlBasePath, spec); + + expect(link.toJSON()).to.have.property('order', 0); + }); + + it('initializes the linkToLastSubUrl property to false when false is specified in the spec', () => { + const urlBasePath = undefined; + const spec = { + id: 'kibana:discover', + title: 'Discover', + order: -1003, + url: '/app/kibana#/discover', + description: 'interactively explore your data', + icon: 'plugins/kibana/assets/discover.svg', + linkToLastSubUrl: false + }; + const link = new UiNavLink(urlBasePath, spec); + + expect(link.toJSON()).to.have.property('linkToLastSubUrl', false); + }); + + it('initializes the linkToLastSubUrl property to true by default', () => { + const urlBasePath = undefined; + const spec = { + id: 'kibana:discover', + title: 'Discover', + order: -1003, + url: '/app/kibana#/discover', + description: 'interactively explore your data', + icon: 'plugins/kibana/assets/discover.svg', + }; + const link = new UiNavLink(urlBasePath, spec); + + expect(link.toJSON()).to.have.property('linkToLastSubUrl', true); + }); + + it('initializes the hidden property to false by default', () => { + const urlBasePath = undefined; + const spec = { + id: 'kibana:discover', + title: 'Discover', + order: -1003, + url: '/app/kibana#/discover', + description: 'interactively explore your data', + icon: 'plugins/kibana/assets/discover.svg', + }; + const link = new UiNavLink(urlBasePath, spec); + + expect(link.toJSON()).to.have.property('hidden', false); + }); + + it('initializes the disabled property to false by default', () => { + const urlBasePath = undefined; + const spec = { + id: 'kibana:discover', + title: 'Discover', + order: -1003, + url: '/app/kibana#/discover', + description: 'interactively explore your data', + icon: 'plugins/kibana/assets/discover.svg', + }; + const link = new UiNavLink(urlBasePath, spec); + + expect(link.toJSON()).to.have.property('disabled', false); + }); + + it('initializes the tooltip property to an empty string by default', () => { + const urlBasePath = undefined; + const spec = { + id: 'kibana:discover', + title: 'Discover', + order: -1003, + url: '/app/kibana#/discover', + description: 'interactively explore your data', + icon: 'plugins/kibana/assets/discover.svg', + }; + const link = new UiNavLink(urlBasePath, spec); + + expect(link.toJSON()).to.have.property('tooltip', ''); + }); + }); +}); diff --git a/src/ui/ui_nav_links/index.js b/src/ui/ui_nav_links/index.js new file mode 100644 index 0000000000000..14968e51cda4e --- /dev/null +++ b/src/ui/ui_nav_links/index.js @@ -0,0 +1,2 @@ +export { UiNavLink } from './ui_nav_link'; +export { uiNavLinksMixin } from './ui_nav_links_mixin'; diff --git a/src/ui/ui_nav_links/ui_nav_link.js b/src/ui/ui_nav_links/ui_nav_link.js new file mode 100644 index 0000000000000..322717c446d96 --- /dev/null +++ b/src/ui/ui_nav_links/ui_nav_link.js @@ -0,0 +1,49 @@ +export class UiNavLink { + constructor(urlBasePath, spec) { + const { + id, + title, + order = 0, + url, + subUrlBase, + description, + icon, + linkToLastSubUrl = true, + hidden = false, + disabled = false, + tooltip = '', + } = spec; + + this._id = id; + this._title = title; + this._order = order; + this._url = `${urlBasePath || ''}${url}`; + this._subUrlBase = `${urlBasePath || ''}${subUrlBase || url}`; + this._description = description; + this._icon = icon; + this._linkToLastSubUrl = linkToLastSubUrl; + this._hidden = hidden; + this._disabled = disabled; + this._tooltip = tooltip; + } + + getOrder() { + return this._order; + } + + toJSON() { + return { + id: this._id, + title: this._title, + order: this._order, + url: this._url, + subUrlBase: this._subUrlBase, + description: this._description, + icon: this._icon, + linkToLastSubUrl: this._linkToLastSubUrl, + hidden: this._hidden, + disabled: this._disabled, + tooltip: this._tooltip, + }; + } +} diff --git a/src/ui/ui_nav_links/ui_nav_links_mixin.js b/src/ui/ui_nav_links/ui_nav_links_mixin.js new file mode 100644 index 0000000000000..38411c799bf34 --- /dev/null +++ b/src/ui/ui_nav_links/ui_nav_links_mixin.js @@ -0,0 +1,23 @@ +import { UiNavLink } from './ui_nav_link'; + +export function uiNavLinksMixin(kbnServer, server, config) { + const uiApps = server.getAllUiApps(); + + const { navLinkSpecs = [] } = kbnServer.uiExports; + const urlBasePath = config.get('server.basePath'); + + const fromSpecs = navLinkSpecs + .map(navLinkSpec => new UiNavLink(urlBasePath, navLinkSpec)); + + const fromApps = uiApps + .map(app => app.getNavLink()) + .filter(Boolean); + + const uiNavLinks = fromSpecs + .concat(fromApps) + .sort((a, b) => a.getOrder() - b.getOrder()); + + server.decorate('server', 'getUiNavLinks', () => ( + uiNavLinks.slice(0) + )); +} diff --git a/src/ui/ui_render/index.js b/src/ui/ui_render/index.js new file mode 100644 index 0000000000000..79bd880c2499d --- /dev/null +++ b/src/ui/ui_render/index.js @@ -0,0 +1 @@ +export { uiRenderMixin } from './ui_render_mixin'; diff --git a/src/ui/ui_render/ui_render_mixin.js b/src/ui/ui_render/ui_render_mixin.js new file mode 100644 index 0000000000000..42c0c066fc747 --- /dev/null +++ b/src/ui/ui_render/ui_render_mixin.js @@ -0,0 +1,121 @@ +import { defaults, get } from 'lodash'; +import { props, reduce as reduceAsync } from 'bluebird'; +import Boom from 'boom'; +import { resolve } from 'path'; + +export function uiRenderMixin(kbnServer, server, config) { + + function replaceInjectedVars(request, injectedVars) { + const { injectedVarsReplacers = [] } = kbnServer.uiExports; + + return reduceAsync( + injectedVarsReplacers, + async (acc, replacer) => await replacer(acc, request, kbnServer.server), + injectedVars + ); + } + + let defaultInjectedVars = {}; + kbnServer.afterPluginsInit(() => { + const { defaultInjectedVarProviders = [] } = kbnServer.uiExports; + defaultInjectedVars = defaultInjectedVarProviders + .reduce((allDefaults, { fn, pluginSpec }) => ( + defaults( + allDefaults, + fn(kbnServer.server, pluginSpec.readConfigValue(kbnServer.config, [])) + ) + ), {}); + }); + + // render all views from ./views + server.setupViews(resolve(__dirname, 'views')); + + server.route({ + path: '/app/{id}', + method: 'GET', + async handler(req, reply) { + const id = req.params.id; + const app = server.getUiAppById(id); + if (!app) return reply(Boom.notFound('Unknown app ' + id)); + + try { + if (kbnServer.status.isGreen()) { + await reply.renderApp(app); + } else { + await reply.renderStatusPage(); + } + } catch (err) { + reply(Boom.wrap(err)); + } + } + }); + + async function getKibanaPayload({ app, request, includeUserProvidedConfig, injectedVarsOverrides }) { + const uiSettings = request.getUiSettingsService(); + const translations = await request.getUiTranslations(); + + return { + app: app, + bundleId: `app:${app.getId()}`, + nav: server.getUiNavLinks(), + version: kbnServer.version, + branch: config.get('pkg.branch'), + buildNum: config.get('pkg.buildNum'), + buildSha: config.get('pkg.buildSha'), + basePath: config.get('server.basePath'), + serverName: config.get('server.name'), + devMode: config.get('env.dev'), + translations: translations, + uiSettings: await props({ + defaults: uiSettings.getDefaults(), + user: includeUserProvidedConfig && uiSettings.getUserProvided() + }), + vars: await replaceInjectedVars( + request, + defaults( + injectedVarsOverrides, + await app.getInjectedVars() || {}, + defaultInjectedVars, + ), + ) + }; + } + + async function renderApp({ app, reply, includeUserProvidedConfig = true, injectedVarsOverrides = {} }) { + try { + const request = reply.request; + const translations = await request.getUiTranslations(); + + return reply.view(app.getTemplateName(), { + app, + kibanaPayload: await getKibanaPayload({ + app, + request, + includeUserProvidedConfig, + injectedVarsOverrides + }), + bundlePath: `${config.get('server.basePath')}/bundles`, + i18n: key => get(translations, key, ''), + }); + } catch (err) { + reply(err); + } + } + + server.decorate('reply', 'renderApp', function (app, injectedVarsOverrides) { + return renderApp({ + app, + reply: this, + includeUserProvidedConfig: true, + injectedVarsOverrides, + }); + }); + + server.decorate('reply', 'renderAppWithDefaultConfig', function (app) { + return renderApp({ + app, + reply: this, + includeUserProvidedConfig: false, + }); + }); +} diff --git a/src/ui/views/chrome.jade b/src/ui/ui_render/views/chrome.jade similarity index 100% rename from src/ui/views/chrome.jade rename to src/ui/ui_render/views/chrome.jade diff --git a/src/ui/views/ui_app.jade b/src/ui/ui_render/views/ui_app.jade similarity index 98% rename from src/ui/views/ui_app.jade rename to src/ui/ui_render/views/ui_app.jade index ee89f8aae99dd..1595059410bfa 100644 --- a/src/ui/views/ui_app.jade +++ b/src/ui/ui_render/views/ui_app.jade @@ -121,9 +121,9 @@ block content } var files = [ bundleFile('commons.style.css'), - bundleFile('#{app.id}.style.css'), + bundleFile('#{app.getId()}.style.css'), bundleFile('commons.bundle.js'), - bundleFile('#{app.id}.bundle.js') + bundleFile('#{app.getId()}.bundle.js') ]; (function next() { diff --git a/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js b/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js index 21dc8781f4bc1..b35caa0e3a73d 100644 --- a/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js +++ b/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js @@ -1,7 +1,7 @@ import sinon from 'sinon'; import expect from 'expect.js'; -import Config from '../../../server/config/config'; +import { Config } from '../../../server/config'; /* eslint-disable import/no-duplicates */ import * as uiSettingsServiceFactoryNS from '../ui_settings_service_factory'; diff --git a/src/ui/ui_settings/ui_settings_mixin.js b/src/ui/ui_settings/ui_settings_mixin.js index 7d2b7bf4397c9..1ba6ce614793e 100644 --- a/src/ui/ui_settings/ui_settings_mixin.js +++ b/src/ui/ui_settings/ui_settings_mixin.js @@ -1,6 +1,5 @@ import { uiSettingsServiceFactory } from './ui_settings_service_factory'; import { getUiSettingsServiceForRequest } from './ui_settings_service_for_request'; -import { UiExportsConsumer } from './ui_exports_consumer'; import { deleteRoute, getRoute, @@ -9,12 +8,8 @@ import { } from './routes'; export function uiSettingsMixin(kbnServer, server) { - // reads the "uiSettingDefaults" from uiExports - const uiExportsConsumer = new UiExportsConsumer(); - kbnServer.uiExports.addConsumer(uiExportsConsumer); - const getDefaults = () => ( - uiExportsConsumer.getUiSettingDefaults() + kbnServer.uiExports.uiSettingDefaults ); server.decorate('server', 'uiSettingsServiceFactory', (options = {}) => { diff --git a/tasks/build/verify_translations.js b/tasks/build/verify_translations.js index 9ed0912ee7903..eee48585f641d 100644 --- a/tasks/build/verify_translations.js +++ b/tasks/build/verify_translations.js @@ -1,10 +1,10 @@ -import Promise from 'bluebird'; -import _ from 'lodash'; - -import { fromRoot } from '../../src/utils'; -import KbnServer from '../../src/server/kbn_server'; +import { fromRoot, formatListAsProse } from '../../src/utils'; import * as i18nVerify from '../utils/i18n_verify_keys'; +import { findPluginSpecs } from '../../src/plugin_discovery'; +import { collectUiExports } from '../../src/ui'; +import { I18n } from '../../src/ui/ui_i18n/i18n'; + export default function (grunt) { grunt.registerTask('_build:verifyTranslations', [ @@ -12,74 +12,63 @@ export default function (grunt) { '_build:check' ]); - grunt.registerTask('_build:check', function () { + grunt.registerTask('_build:check', async function () { const done = this.async(); - const serverConfig = { - env: 'production', - logging: { - silent: true, - quiet: true, - verbose: false - }, - optimize: { - useBundleCache: false, - enabled: false - }, - server: { - autoListen: false - }, - plugins: { - initialize: true, - scanDirs: [fromRoot('src/core_plugins')] - }, - uiSettings: { - enabled: false - } - }; - - const kbnServer = new KbnServer(serverConfig); - kbnServer.ready() - .then(() => verifyTranslations(kbnServer.uiI18n)) - .then(() => kbnServer.close()) - .then(done) - .catch((err) => { - kbnServer.close() - .then(() => done(err)); + try { + const { spec$ } = findPluginSpecs({ + env: 'production', + plugins: { + scanDirs: [fromRoot('src/core_plugins')] + } }); + + const specs = await spec$.toArray().toPromise(); + const uiExports = collectUiExports(specs); + await verifyTranslations(uiExports); + + done(); + } catch (error) { + done(error); + } }); } -function verifyTranslations(uiI18nObj) -{ +async function verifyTranslations(uiExports) { const angularTranslations = require(fromRoot('build/i18n_extract/en.json')); - const translationKeys = Object.keys(angularTranslations); + const keysUsedInViews = Object.keys(angularTranslations); + + // Search files for used translation keys const translationPatterns = [ - { regEx: 'i18n\\(\'(.*)\'\\)', - parsePaths: [fromRoot('/src/ui/views/*.jade')] } + { regexp: 'i18n\\(\'(.*)\'\\)', + parsePaths: [fromRoot('src/ui/ui_render/views/*.jade')] } ]; + for (const { regexp, parsePaths } of translationPatterns) { + const keys = await i18nVerify.getTranslationKeys(regexp, parsePaths); + for (const key of keys) { + keysUsedInViews.push(key); + } + } - const keyPromises = _.map(translationPatterns, (pattern) => { - return i18nVerify.getTranslationKeys(pattern.regEx, pattern.parsePaths) - .then(function (keys) { - const arrayLength = keys.length; - for (let i = 0; i < arrayLength; i++) { - translationKeys.push(keys[i]); - } - }); - }); + // get all of the translations from uiExports + const translations = await I18n.getAllTranslationsFromPaths(uiExports.translationPaths); + const keysWithoutTranslations = Object.entries( + i18nVerify.getNonTranslatedKeys(keysUsedInViews, translations) + ); + + if (!keysWithoutTranslations.length) { + return; + } - return Promise.all(keyPromises) - .then(function () { - return uiI18nObj.getAllTranslations() - .then(function (translations) { - const keysNotTranslatedPerLocale = i18nVerify.getNonTranslatedKeys(translationKeys, translations); - if (!_.isEmpty(keysNotTranslatedPerLocale)) { - const str = JSON.stringify(keysNotTranslatedPerLocale); - const errMsg = 'The following translation keys per locale are not translated: ' + str; - throw new Error(errMsg); - } - }); - }); + throw new Error( + '\n' + + '\n' + + 'The following keys are used in angular/jade views but are not translated:\n' + + keysWithoutTranslations.map(([locale, keys]) => ( + ` - ${locale}: ${formatListAsProse(keys)}` + )).join('\n') + + '\n' + + '\n' + ); }