diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index fbdd29dd8104c..1632d389a7a8f 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -12,6 +12,7 @@ import scripts from './server/routes/api/scripts'; import { registerSuggestionsApi } from './server/routes/api/suggestions'; import * as systemApi from './server/lib/system_api'; import mappings from './mappings.json'; +import { getUiSettingDefaults } from './ui_setting_defaults'; const mkdirp = Promise.promisify(mkdirpNode); @@ -130,7 +131,9 @@ module.exports = function (kibana) { translations: [ resolve(__dirname, './translations/en.json') ], - mappings + + mappings, + uiSettingDefaults: getUiSettingDefaults(), }, preInit: async function (server) { diff --git a/src/core_plugins/kibana/server/routes/api/settings/register_delete.js b/src/core_plugins/kibana/server/routes/api/settings/register_delete.js index ea68620298d3d..1b78e83b5eac6 100644 --- a/src/core_plugins/kibana/server/routes/api/settings/register_delete.js +++ b/src/core_plugins/kibana/server/routes/api/settings/register_delete.js @@ -6,11 +6,12 @@ export default function registerDelete(server) { method: 'DELETE', handler: function (req, reply) { const { key } = req.params; - const uiSettings = server.uiSettings(); + const uiSettings = req.getUiSettingsService(); + uiSettings - .remove(req, key) + .remove(key) .then(() => uiSettings - .getUserProvided(req) + .getUserProvided() .then(settings => reply({ settings }).type('application/json')) ) .catch(err => reply(Boom.wrap(err, err.statusCode))); diff --git a/src/core_plugins/kibana/server/routes/api/settings/register_get.js b/src/core_plugins/kibana/server/routes/api/settings/register_get.js index 85fc8c9ca5f27..123f869ef0672 100644 --- a/src/core_plugins/kibana/server/routes/api/settings/register_get.js +++ b/src/core_plugins/kibana/server/routes/api/settings/register_get.js @@ -5,9 +5,9 @@ export default function registerGet(server) { path: '/api/kibana/settings', method: 'GET', handler: function (req, reply) { - server - .uiSettings() - .getUserProvided(req) + req + .getUiSettingsService() + .getUserProvided() .then(settings => reply({ settings }).type('application/json')) .catch(err => reply(Boom.wrap(err, err.statusCode))); } diff --git a/src/core_plugins/kibana/server/routes/api/settings/register_set.js b/src/core_plugins/kibana/server/routes/api/settings/register_set.js index 8d572bbd1ab25..f82cafef43324 100644 --- a/src/core_plugins/kibana/server/routes/api/settings/register_set.js +++ b/src/core_plugins/kibana/server/routes/api/settings/register_set.js @@ -7,11 +7,12 @@ export default function registerSet(server) { handler: function (req, reply) { const { key } = req.params; const { value } = req.payload; - const uiSettings = server.uiSettings(); + const uiSettings = req.getUiSettingsService(); + uiSettings - .set(req, key, value) + .set(key, value) .then(() => uiSettings - .getUserProvided(req) + .getUserProvided() .then(settings => reply({ settings }).type('application/json')) ) .catch(err => reply(Boom.wrap(err, err.statusCode))); diff --git a/src/core_plugins/kibana/server/routes/api/settings/register_set_many.js b/src/core_plugins/kibana/server/routes/api/settings/register_set_many.js index 57ce8357fb1d2..71e873bd80d79 100644 --- a/src/core_plugins/kibana/server/routes/api/settings/register_set_many.js +++ b/src/core_plugins/kibana/server/routes/api/settings/register_set_many.js @@ -6,11 +6,12 @@ export default function registerSet(server) { method: 'POST', handler: function (req, reply) { const { changes } = req.payload; - const uiSettings = server.uiSettings(); + const uiSettings = req.getUiSettingsService(); + uiSettings - .setMany(req, changes) + .setMany(changes) .then(() => uiSettings - .getUserProvided(req) + .getUserProvided() .then(settings => reply({ settings }).type('application/json')) ) .catch(err => reply(Boom.wrap(err, err.statusCode))); diff --git a/src/ui/ui_settings/defaults.js b/src/core_plugins/kibana/ui_setting_defaults.js similarity index 89% rename from src/ui/ui_settings/defaults.js rename to src/core_plugins/kibana/ui_setting_defaults.js index c7cc2555de98e..1c53ae111307d 100644 --- a/src/ui/ui_settings/defaults.js +++ b/src/core_plugins/kibana/ui_setting_defaults.js @@ -1,6 +1,6 @@ import moment from 'moment-timezone'; -export function getDefaultSettings() { +export function getUiSettingDefaults() { const weekdays = moment.weekdays().slice(); const [defaultWeekday] = weekdays; @@ -283,47 +283,6 @@ export function getDefaultSettings() { value: 2000, description: 'The maximum number of buckets a single datasource can return' }, - // Timelion stuff - 'timelion:showTutorial': { - value: false, - description: 'Should I show the tutorial by default when entering the timelion app?' - }, - 'timelion:es.timefield': { - value: '@timestamp', - description: 'Default field containing a timestamp when using .es()' - }, - 'timelion:es.default_index': { - value: '_all', - description: 'Default elasticsearch index to search with .es()' - }, - 'timelion:target_buckets': { - value: 200, - description: 'The number of buckets to shoot for when using auto intervals' - }, - 'timelion:max_buckets': { - value: 2000, - description: 'The maximum number of buckets a single datasource can return' - }, - 'timelion:default_columns': { - value: 2, - description: 'Number of columns on a timelion sheet by default' - }, - 'timelion:default_rows': { - value: 2, - description: 'Number of rows on a timelion sheet by default' - }, - 'timelion:min_interval': { - value: '1ms', - description: 'The smallest interval that will be calculated when using "auto"' - }, - 'timelion:graphite.url': { - value: 'https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite', - description: '[experimental] The URL of your graphite host' - }, - 'timelion:quandl.key': { - value: 'someKeyHere', - description: '[experimental] Your API key from www.quandl.com' - }, 'state:storeInSessionStorage': { value: false, description: 'The URL can sometimes grow to be too large for some browsers to ' + diff --git a/src/core_plugins/tests_bundle/index.js b/src/core_plugins/tests_bundle/index.js index e78f4bd008df9..335457290293e 100644 --- a/src/core_plugins/tests_bundle/index.js +++ b/src/core_plugins/tests_bundle/index.js @@ -1,5 +1,4 @@ import { union } from 'lodash'; -import { getDefaultSettings } from '../../ui/ui_settings/defaults'; import findSourceFiles from './find_source_files'; import { fromRoot } from '../../utils'; @@ -57,7 +56,11 @@ export default (kibana) => { }); } - env.defaultUiSettings = getDefaultSettings(); + 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({ id: 'tests', diff --git a/src/core_plugins/timelion/index.js b/src/core_plugins/timelion/index.js index 1da6f20a58a60..bf89fe194fa28 100644 --- a/src/core_plugins/timelion/index.js +++ b/src/core_plugins/timelion/index.js @@ -36,7 +36,50 @@ module.exports = function (kibana) { visTypes: [ 'plugins/timelion/vis' ], - mappings: require('./mappings.json') + mappings: require('./mappings.json'), + + uiSettingDefaults: { + 'timelion:showTutorial': { + value: false, + description: 'Should I show the tutorial by default when entering the timelion app?' + }, + 'timelion:es.timefield': { + value: '@timestamp', + description: 'Default field containing a timestamp when using .es()' + }, + 'timelion:es.default_index': { + value: '_all', + description: 'Default elasticsearch index to search with .es()' + }, + 'timelion:target_buckets': { + value: 200, + description: 'The number of buckets to shoot for when using auto intervals' + }, + 'timelion:max_buckets': { + value: 2000, + description: 'The maximum number of buckets a single datasource can return' + }, + 'timelion:default_columns': { + value: 2, + description: 'Number of columns on a timelion sheet by default' + }, + 'timelion:default_rows': { + value: 2, + description: 'Number of rows on a timelion sheet by default' + }, + 'timelion:min_interval': { + value: '1ms', + description: 'The smallest interval that will be calculated when using "auto"' + }, + 'timelion:graphite.url': { + value: 'https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite', + description: '[experimental] The URL of your graphite host' + }, + 'timelion:quandl.key': { + value: 'someKeyHere', + description: '[experimental] Your API key from www.quandl.com' + } + } }, init: require('./init.js'), }); diff --git a/src/core_plugins/timelion/server/routes/run.js b/src/core_plugins/timelion/server/routes/run.js index 6b5fee71f557a..3b4c349395cc8 100644 --- a/src/core_plugins/timelion/server/routes/run.js +++ b/src/core_plugins/timelion/server/routes/run.js @@ -14,7 +14,7 @@ module.exports = (server) => { path: '/api/timelion/run', handler: async (request, reply) => { try { - const uiSettings = await server.uiSettings().getAll(request); + const uiSettings = await request.getUiSettingsService().getAll(); const tlConfig = require('../handlers/lib/tl_config.js')({ server, diff --git a/src/core_plugins/timelion/server/routes/validate_es.js b/src/core_plugins/timelion/server/routes/validate_es.js index 6ade46b11dc25..5b61cbd4e7d92 100644 --- a/src/core_plugins/timelion/server/routes/validate_es.js +++ b/src/core_plugins/timelion/server/routes/validate_es.js @@ -3,8 +3,7 @@ module.exports = function (server) { method: 'GET', path: '/api/timelion/validate/es', handler: function (request, reply) { - return server.uiSettings().getAll(request).then((uiSettings) => { - + return request.getUiSettingsService().getAll().then((uiSettings) => { const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); const timefield = uiSettings['timelion:es.timefield']; diff --git a/src/functional_test_runner/cli.js b/src/functional_test_runner/cli.js index 362176bc6fcba..a34a1511cf179 100644 --- a/src/functional_test_runner/cli.js +++ b/src/functional_test_runner/cli.js @@ -44,9 +44,9 @@ async function run() { const failureCount = await functionalTestRunner.run(); process.exitCode = failureCount ? 1 : 0; } catch (err) { - // await teardown(err); + await teardown(err); } finally { - // await teardown(); + await teardown(); } } diff --git a/src/server/http/index.js b/src/server/http/index.js index 5564bb4ed6d50..e458f7183b07f 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -120,8 +120,8 @@ module.exports = async function (kbnServer, server, config) { const url = await shortUrlLookup.getUrl(request.params.urlId, request); shortUrlAssertValid(url); - const uiSettings = server.uiSettings(); - const stateStoreInSessionStorage = await uiSettings.get(request, 'state:storeInSessionStorage'); + const uiSettings = request.getUiSettingsService(); + const stateStoreInSessionStorage = await uiSettings.get('state:storeInSessionStorage'); if (!stateStoreInSessionStorage) { reply().redirect(config.get('server.basePath') + url); return; diff --git a/src/server/kbn_server.js b/src/server/kbn_server.js index d35c9c1030737..51dd45bb9add7 100644 --- a/src/server/kbn_server.js +++ b/src/server/kbn_server.js @@ -16,7 +16,6 @@ import pluginsCheckEnabledMixin from './plugins/check_enabled'; import pluginsCheckVersionMixin from './plugins/check_version'; import configCompleteMixin from './config/complete'; import uiMixin from '../ui'; -import { uiSettingsMixin } from '../ui'; import optimizeMixin from '../optimize'; import pluginsInitializeMixin from './plugins/initialize'; import { indexPatternsMixin } from './index_patterns'; @@ -63,9 +62,6 @@ module.exports = class KbnServer { // setup saved object routes savedObjectsMixin, - // setup server.uiSettings - uiSettingsMixin, - // ensure that all bundles are built, or that the // lazy bundle server is running optimizeMixin, diff --git a/src/ui/__tests__/ui_exports_replace_injected_vars.js b/src/ui/__tests__/ui_exports_replace_injected_vars.js index 6ebd06df03237..662236464c90f 100644 --- a/src/ui/__tests__/ui_exports_replace_injected_vars.js +++ b/src/ui/__tests__/ui_exports_replace_injected_vars.js @@ -40,7 +40,7 @@ describe('UiExports', function () { await kbnServer.ready(); kbnServer.status.get('ui settings').state = 'green'; - kbnServer.server.decorate('server', 'uiSettings', () => { + kbnServer.server.decorate('request', 'getUiSettingsService', () => { return { getDefaults: noop, getUserProvided: noop }; }); }); diff --git a/src/ui/index.js b/src/ui/index.js index cec250e9d1b8e..97e176201cbac 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -10,13 +10,15 @@ import UiBundleCollection from './ui_bundle_collection'; import UiBundlerEnv from './ui_bundler_env'; import { UiI18n } from './ui_i18n'; -export { uiSettingsMixin } from './ui_settings'; +import { uiSettingsMixin } from './ui_settings'; export default async (kbnServer, server, config) => { const uiExports = kbnServer.uiExports = new UiExports({ urlBasePath: config.get('server.basePath') }); + await kbnServer.mixin(uiSettingsMixin); + const uiI18n = kbnServer.uiI18n = new UiI18n(config.get('i18n.defaultLocale')); uiI18n.addUiExportConsumer(uiExports); @@ -67,7 +69,7 @@ export default async (kbnServer, server, config) => { }); async function getKibanaPayload({ app, request, includeUserProvidedConfig, injectedVarsOverrides }) { - const uiSettings = server.uiSettings(); + const uiSettings = request.getUiSettingsService(); const translations = await uiI18n.getTranslationsForRequest(request); return { @@ -83,7 +85,7 @@ export default async (kbnServer, server, config) => { translations: translations, uiSettings: await props({ defaults: uiSettings.getDefaults(), - user: includeUserProvidedConfig && uiSettings.getUserProvided(request) + user: includeUserProvidedConfig && uiSettings.getUserProvided() }), vars: await reduceAsync( uiExports.injectedVarsReplacers, diff --git a/src/ui/ui_settings/__tests__/index.js b/src/ui/ui_settings/__tests__/index.js deleted file mode 100644 index 40c681f2b9765..0000000000000 --- a/src/ui/ui_settings/__tests__/index.js +++ /dev/null @@ -1,497 +0,0 @@ -import { isEqual } from 'lodash'; -import sinon from 'sinon'; -import expect from 'expect.js'; -import { uiSettingsMixin } from '../ui_settings_mixin'; -import { getDefaultSettings } from '../defaults'; -import { errors as esErrors } from 'elasticsearch'; - -async function expectRejection(promise, errorMessageContain) { - if (!promise || typeof promise.then !== 'function') { - throw new Error('Expected function to return a promise'); - } - - try { - await promise; - } catch (err) { - expect(err.message).to.contain(errorMessageContain); - } -} - -describe('ui settings', function () { - describe('overview', function () { - it('has expected api surface', function () { - const { uiSettings } = instantiate(); - expect(typeof uiSettings.get).to.equal('function'); - expect(typeof uiSettings.getAll).to.equal('function'); - expect(typeof uiSettings.getDefaults).to.equal('function'); - expect(typeof uiSettings.getRaw).to.equal('function'); - expect(typeof uiSettings.getUserProvided).to.equal('function'); - expect(typeof uiSettings.remove).to.equal('function'); - expect(typeof uiSettings.removeMany).to.equal('function'); - expect(typeof uiSettings.set).to.equal('function'); - expect(typeof uiSettings.setMany).to.equal('function'); - }); - - it('throws if the first error is not a request', async () => { - const { uiSettings } = instantiate(); - await expectRejection(uiSettings.get(null), 'hapi.Request'); - await expectRejection(uiSettings.get(false), 'hapi.Request'); - await expectRejection(uiSettings.get('key'), 'hapi.Request'); - await expectRejection(uiSettings.get(/regex/), 'hapi.Request'); - await expectRejection(uiSettings.get(new Date()), 'hapi.Request'); - await expectRejection(uiSettings.get({}), 'hapi.Request'); - await expectRejection(uiSettings.get({ path:'' }), 'hapi.Request'); - await expectRejection(uiSettings.get({ path:'', headers:null }), 'hapi.Request'); - await expectRejection(uiSettings.get({ headers:{} }), 'hapi.Request'); - }); - }); - - describe('#setMany()', function () { - it('returns a promise', () => { - const { uiSettings, req } = instantiate(); - const result = uiSettings.setMany(req, { a: 'b' }); - expect(result).to.be.a(Promise); - }); - - it('updates a single value in one operation', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.setMany(req, { one: 'value' }); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: 'value' - }); - }); - - it('updates several values in one operation', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.setMany(req, { one: 'value', another: 'val' }); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: 'value', another: 'val' - }); - }); - }); - - describe('#set()', function () { - it('returns a promise', () => { - const { uiSettings, req } = instantiate(); - const result = uiSettings.set(req, 'a', 'b'); - expect(result).to.be.a(Promise); - }); - - it('updates single values by (key, value)', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.set(req, 'one', 'value'); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: 'value' - }); - }); - }); - - describe('#remove()', function () { - it('returns a promise', () => { - const { uiSettings, req } = instantiate(); - const result = uiSettings.remove(req, 'one'); - expect(result).to.be.a(Promise); - }); - - it('removes single values by key', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.remove(req, 'one'); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: null - }); - }); - }); - - describe('#removeMany()', function () { - it('returns a promise', () => { - const { uiSettings, req } = instantiate(); - const result = uiSettings.removeMany(req, ['one']); - expect(result).to.be.a(Promise); - }); - - it('removes a single value', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.removeMany(req, ['one']); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: null - }); - }); - - it('updates several values in one operation', function () { - const { server, uiSettings, configGet, req } = instantiate(); - uiSettings.removeMany(req, ['one', 'two', 'three']); - expectElasticsearchUpdateQuery(server, req, configGet, { - one: null, two: null, three: null - }); - }); - }); - - describe('#getDefaults()', function () { - it('is promised the default values', async function () { - const { - uiSettings - } = instantiate(); - const defaults = await uiSettings.getDefaults(); - expect(isEqual(defaults, getDefaultSettings())).to.equal(true); - }); - - - describe('defaults for formatters', async function () { - - const defaults = getDefaultSettings(); - const mapping = JSON.parse(defaults['format:defaultTypeMap'].value); - const expected = { - ip: { id: 'ip', params: {} }, - date: { id: 'date', params: {} }, - number: { id: 'number', params: {} }, - boolean: { id: 'boolean', params: {} }, - _source: { id: '_source', params: {} }, - _default_: { id: 'string', params: {} } - }; - - Object.keys(mapping).forEach(function (dataType) { - it(`should configure ${dataType}`, function () { - expect(expected.hasOwnProperty(dataType)).to.equal(true); - expect(mapping[dataType].id).to.equal(expected[dataType].id); - expect(JSON.stringify(mapping[dataType].params)).to.equal(JSON.stringify(expected[dataType].params)); - }); - }); - }); - }); - - describe('#getUserProvided()', function () { - it('pulls user configuration from ES', async function () { - const getResult = { user: 'customized' }; - const { server, uiSettings, configGet, req } = instantiate({ getResult }); - await uiSettings.getUserProvided(req); - expectElasticsearchGetQuery(server, req, configGet); - }); - - it('returns user configuration', async function () { - const getResult = { user: 'customized' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getUserProvided(req); - expect(isEqual(result, { - user: { userValue: 'customized' } - })).to.equal(true); - }); - - it('ignores null user configuration (because default values)', async function () { - const getResult = { user: 'customized', usingDefault: null, something: 'else' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getUserProvided(req); - expect(isEqual(result, { - user: { userValue: 'customized' }, something: { userValue: 'else' } - })).to.equal(true); - }); - - it('returns an empty object when status is not green', async function () { - const { uiSettings, req } = instantiate({ - settingsStatusOverrides: { state: 'yellow' } - }); - - expect(await uiSettings.getUserProvided(req)).to.eql({}); - }); - - it('returns an empty object on 404 responses', async function () { - const { uiSettings, req } = instantiate({ - async callWithRequest() { - throw new esErrors[404](); - } - }); - - expect(await uiSettings.getUserProvided(req)).to.eql({}); - }); - - it('returns an empty object on 403 responses', async function () { - const { uiSettings, req } = instantiate({ - async callWithRequest() { - throw new esErrors[403](); - } - }); - - expect(await uiSettings.getUserProvided(req)).to.eql({}); - }); - - it('returns an empty object on NoConnections responses', async function () { - const { uiSettings, req } = instantiate({ - async callWithRequest() { - throw new esErrors.NoConnections(); - } - }); - - expect(await uiSettings.getUserProvided(req)).to.eql({}); - }); - - it('throws 401 errors', async function () { - const { uiSettings, req } = instantiate({ - async callWithRequest() { - throw new esErrors[401](); - } - }); - - try { - await uiSettings.getUserProvided(req); - throw new Error('expect getUserProvided() to throw'); - } catch (err) { - expect(err).to.be.a(esErrors[401]); - } - }); - - it('throw when callWithRequest fails in some unexpected way', async function () { - const expectedUnexpectedError = new Error('unexpected'); - - const { uiSettings, req } = instantiate({ - async callWithRequest() { - throw expectedUnexpectedError; - } - }); - - try { - await uiSettings.getUserProvided(req); - throw new Error('expect getUserProvided() to throw'); - } catch (err) { - expect(err).to.be(expectedUnexpectedError); - } - }); - }); - - describe('#getRaw()', function () { - it('pulls user configuration from ES', async function () { - const getResult = {}; - const { server, uiSettings, configGet, req } = instantiate({ getResult }); - await uiSettings.getRaw(req); - expectElasticsearchGetQuery(server, req, configGet); - }); - - it(`without user configuration it's equal to the defaults`, async function () { - const getResult = {}; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getRaw(req); - expect(isEqual(result, getDefaultSettings())).to.equal(true); - }); - - it(`user configuration gets merged with defaults`, async function () { - const getResult = { foo: 'bar' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getRaw(req); - const merged = getDefaultSettings(); - merged.foo = { userValue: 'bar' }; - expect(isEqual(result, merged)).to.equal(true); - }); - - it(`user configuration gets merged into defaults`, async function () { - const getResult = { dateFormat: 'YYYY-MM-DD' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getRaw(req); - const merged = getDefaultSettings(); - merged.dateFormat.userValue = 'YYYY-MM-DD'; - expect(isEqual(result, merged)).to.equal(true); - }); - }); - - describe('#getAll()', function () { - it('pulls user configuration from ES', async function () { - const getResult = {}; - const { server, uiSettings, configGet, req } = instantiate({ getResult }); - await uiSettings.getAll(req); - expectElasticsearchGetQuery(server, req, configGet); - }); - - it(`returns key value pairs`, async function () { - const getResult = {}; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getAll(req); - const defaults = getDefaultSettings(); - const expectation = {}; - Object.keys(defaults).forEach(key => { - expectation[key] = defaults[key].value; - }); - expect(isEqual(result, expectation)).to.equal(true); - }); - - it(`returns key value pairs including user configuration`, async function () { - const getResult = { something: 'user-provided' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getAll(req); - const defaults = getDefaultSettings(); - const expectation = {}; - Object.keys(defaults).forEach(key => { - expectation[key] = defaults[key].value; - }); - expectation.something = 'user-provided'; - expect(isEqual(result, expectation)).to.equal(true); - }); - - it(`returns key value pairs including user configuration for existing settings`, async function () { - const getResult = { dateFormat: 'YYYY-MM-DD' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.getAll(req); - const defaults = getDefaultSettings(); - const expectation = {}; - Object.keys(defaults).forEach(key => { - expectation[key] = defaults[key].value; - }); - expectation.dateFormat = 'YYYY-MM-DD'; - expect(isEqual(result, expectation)).to.equal(true); - }); - }); - - describe('#get()', function () { - it('pulls user configuration from ES', async function () { - const getResult = {}; - const { server, uiSettings, configGet, req } = instantiate({ getResult }); - await uiSettings.get(req); - expectElasticsearchGetQuery(server, req, configGet); - }); - - it(`returns the promised value for a key`, async function () { - const getResult = {}; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.get(req, 'dateFormat'); - const defaults = getDefaultSettings(); - expect(result).to.equal(defaults.dateFormat.value); - }); - - it(`returns the user-configured value for a custom key`, async function () { - const getResult = { custom: 'value' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.get(req, 'custom'); - expect(result).to.equal('value'); - }); - - it(`returns the user-configured value for a modified key`, async function () { - const getResult = { dateFormat: 'YYYY-MM-DD' }; - const { - uiSettings, - req - } = instantiate({ getResult }); - const result = await uiSettings.get(req, 'dateFormat'); - expect(result).to.equal('YYYY-MM-DD'); - }); - }); -}); - -function expectElasticsearchGetQuery(server, req, configGet) { - const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); - sinon.assert.calledOnce(callWithRequest); - const [reqPassed, method, params] = callWithRequest.args[0]; - expect(reqPassed).to.be(req); - expect(method).to.be('get'); - expect(params).to.eql({ - index: configGet('kibana.index'), - id: configGet('pkg.version'), - type: 'config' - }); -} - -function expectElasticsearchUpdateQuery(server, req, configGet, doc) { - const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); - sinon.assert.calledOnce(callWithRequest); - const [reqPassed, method, params] = callWithRequest.args[0]; - expect(reqPassed).to.be(req); - expect(method).to.be('update'); - expect(params).to.eql({ - index: configGet('kibana.index'), - id: configGet('pkg.version'), - type: 'config', - body: { doc } - }); -} - -function instantiate({ getResult, callWithRequest, settingsStatusOverrides } = {}) { - const esStatus = { - state: 'green', - on: sinon.spy() - }; - const settingsStatus = { - state: 'green', - red: sinon.spy(), - yellow: sinon.spy(), - green: sinon.spy(), - ...settingsStatusOverrides - }; - const kbnServer = { - status: { - create: sinon.stub().withArgs('ui settings').returns(settingsStatus), - getForPluginId: sinon.stub().withArgs('elasticsearch').returns(esStatus) - }, - ready: sinon.stub().returns(Promise.resolve()) - }; - - const req = { __stubHapiRequest: true, path: '', headers: {} }; - - const adminCluster = { - errors: esErrors, - callWithInternalUser: sinon.stub(), - callWithRequest: sinon.spy((withReq, method, params) => { - if (callWithRequest) { - return callWithRequest(withReq, method, params); - } - - expect(withReq).to.be(req); - switch (method) { - case 'get': - return Promise.resolve({ _source: getResult }); - case 'update': - return Promise.resolve(); - default: - throw new Error(`callWithRequest() is using unexpected method "${method}"`); - } - }) - }; - - adminCluster.callWithInternalUser.withArgs('get', sinon.match.any).returns(Promise.resolve({ _source: getResult })); - adminCluster.callWithInternalUser.withArgs('update', sinon.match.any).returns(Promise.resolve()); - - const configGet = sinon.stub(); - configGet.withArgs('kibana.index').returns('.kibana'); - configGet.withArgs('pkg.version').returns('1.2.3-test'); - configGet.withArgs('uiSettings.enabled').returns(true); - const config = { - get: configGet - }; - - const server = { - config: () => config, - decorate: (_, key, value) => server[key] = value, - plugins: { - elasticsearch: { - getCluster: sinon.stub().withArgs('admin').returns(adminCluster) - } - } - }; - uiSettingsMixin(kbnServer, server, config); - const uiSettings = server.uiSettings(); - return { server, uiSettings, configGet, req }; -} diff --git a/src/ui/ui_settings/__tests__/lib/call_cluster_stub.js b/src/ui/ui_settings/__tests__/lib/call_cluster_stub.js new file mode 100644 index 0000000000000..568c18388912d --- /dev/null +++ b/src/ui/ui_settings/__tests__/lib/call_cluster_stub.js @@ -0,0 +1,41 @@ +import sinon from 'sinon'; +import expect from 'expect.js'; + +export function createCallClusterStub(index, type, id, esDocSource) { + const callCluster = sinon.spy(async (method, params) => { + expect(params) + .to.have.property('index', index) + .and.to.have.property('type', type) + .and.to.have.property('id', id); + + switch (method) { + case 'get': + return { _source: { ...esDocSource } }; + + case 'update': + expect(params).to.have.property('body'); + expect(params.body).to.have.property('doc'); + return {}; + + default: + throw new Error(`unexpected es method ${method}`); + } + }); + + callCluster.assertGetQuery = () => { + sinon.assert.calledOnce(callCluster); + sinon.assert.calledWith(callCluster, 'get'); + }; + + callCluster.assertUpdateQuery = doc => { + sinon.assert.calledOnce(callCluster); + sinon.assert.calledWithExactly(callCluster, 'update', { + index, + type, + id, + body: { doc } + }); + }; + + return callCluster; +} diff --git a/src/ui/ui_settings/__tests__/lib/index.js b/src/ui/ui_settings/__tests__/lib/index.js new file mode 100644 index 0000000000000..f3cd36fad2691 --- /dev/null +++ b/src/ui/ui_settings/__tests__/lib/index.js @@ -0,0 +1 @@ +export { createCallClusterStub } from './call_cluster_stub'; diff --git a/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js b/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js new file mode 100644 index 0000000000000..a81d7d6d8d3a3 --- /dev/null +++ b/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js @@ -0,0 +1,206 @@ +import sinon from 'sinon'; +import expect from 'expect.js'; +import Chance from 'chance'; + +import ServerStatus from '../../../server/status/server_status'; +import Config from '../../../server/config/config'; + +/* eslint-disable import/no-duplicates */ +import * as uiSettingsServiceFactoryNS from '../ui_settings_service_factory'; +import { uiSettingsServiceFactory } from '../ui_settings_service_factory'; +import * as getUiSettingsServiceForRequestNS from '../ui_settings_service_for_request'; +import { getUiSettingsServiceForRequest } from '../ui_settings_service_for_request'; +/* eslint-enable import/no-duplicates */ + +import { uiSettingsMixin } from '../ui_settings_mixin'; + +const chance = new Chance(); + +describe('uiSettingsMixin()', () => { + const sandbox = sinon.sandbox.create(); + + function setup(options = {}) { + const { + enabled = true + } = options; + + const config = Config.withDefaultSchema({ + uiSettings: { enabled } + }); + + // maps of decorations passed to `server.decorate()` + const decorations = { + server: {}, + request: {} + }; + + // mock hapi server + const server = { + log: sinon.stub(), + config: () => config, + decorate: sinon.spy((type, name, value) => { + decorations[type][name] = value; + }), + }; + + // "promise" returned from kbnServer.ready() + const readyPromise = { + then: sinon.stub(), + }; + + const kbnServer = { + server, + config, + uiExports: { addConsumer: sinon.stub() }, + status: new ServerStatus(server), + ready: sinon.stub().returns(readyPromise), + }; + + uiSettingsMixin(kbnServer, server, config); + + return { + kbnServer, + server, + decorations, + readyPromise, + status: kbnServer.status.get('ui settings'), + }; + } + + afterEach(() => sandbox.restore()); + + describe('status', () => { + it('creates a "ui settings" status', () => { + const { status } = setup(); + expect(status).to.have.property('state', 'uninitialized'); + }); + + describe('disabled', () => { + it('disables if uiSettings.enabled config is false', () => { + const { status } = setup({ enabled: false }); + expect(status).to.have.property('state', 'disabled'); + }); + + it('does not register a handler for kbnServer.ready()', () => { + const { readyPromise } = setup({ enabled: false }); + sinon.assert.notCalled(readyPromise.then); + }); + }); + + describe('enabled', () => { + it('registers a handler for kbnServer.ready()', () => { + const { readyPromise } = setup(); + sinon.assert.calledOnce(readyPromise.then); + }); + + it('mirrors the elasticsearch plugin status once kibanaServer.ready() resolves', () => { + const { kbnServer, readyPromise, status } = setup(); + const esStatus = kbnServer.status.createForPlugin({ + id: 'elasticsearch', + version: 'kibana', + }); + + esStatus.green(); + expect(status).to.have.property('state', 'uninitialized'); + const readyPromiseHandler = readyPromise.then.firstCall.args[0]; + readyPromiseHandler(); + expect(status).to.have.property('state', 'green'); + + + const states = chance.shuffle(['red', 'green', 'yellow']); + states.forEach((state) => { + esStatus[state](); + expect(esStatus).to.have.property('state', state); + expect(status).to.have.property('state', state); + }); + }); + }); + }); + + describe('server.uiSettingsServiceFactory()', () => { + it('decorates server with "uiSettingsServiceFactory"', () => { + const { decorations } = setup(); + expect(decorations.server).to.have.property('uiSettingsServiceFactory').a('function'); + + sandbox.stub(uiSettingsServiceFactoryNS, 'uiSettingsServiceFactory'); + sinon.assert.notCalled(uiSettingsServiceFactory); + decorations.server.uiSettingsServiceFactory(); + sinon.assert.calledOnce(uiSettingsServiceFactory); + }); + + it('passes `server` and `options` argument to factory', () => { + const { decorations, server } = setup(); + expect(decorations.server).to.have.property('uiSettingsServiceFactory').a('function'); + + sandbox.stub(uiSettingsServiceFactoryNS, 'uiSettingsServiceFactory'); + sinon.assert.notCalled(uiSettingsServiceFactory); + decorations.server.uiSettingsServiceFactory({ + foo: 'bar' + }); + sinon.assert.calledOnce(uiSettingsServiceFactory); + sinon.assert.calledWithExactly(uiSettingsServiceFactory, server, { + foo: 'bar', + getDefaults: sinon.match.func, + }); + }); + }); + + describe('request.getUiSettingsService()', () => { + it('exposes "getUiSettingsService" on requests', () => { + const { decorations } = setup(); + expect(decorations.request).to.have.property('getUiSettingsService').a('function'); + + sandbox.stub(getUiSettingsServiceForRequestNS, 'getUiSettingsServiceForRequest'); + sinon.assert.notCalled(getUiSettingsServiceForRequest); + decorations.request.getUiSettingsService(); + sinon.assert.calledOnce(getUiSettingsServiceForRequest); + }); + + it('passes request to getUiSettingsServiceForRequest', () => { + const { server, decorations } = setup(); + expect(decorations.request).to.have.property('getUiSettingsService').a('function'); + + sandbox.stub(getUiSettingsServiceForRequestNS, 'getUiSettingsServiceForRequest'); + sinon.assert.notCalled(getUiSettingsServiceForRequest); + const request = {}; + decorations.request.getUiSettingsService.call(request); + sinon.assert.calledWith(getUiSettingsServiceForRequest, server, request); + }); + + it('defines read interceptor that intercepts when status is not green', () => { + const { status, decorations } = setup(); + expect(decorations.request).to.have.property('getUiSettingsService').a('function'); + + sandbox.stub(getUiSettingsServiceForRequestNS, 'getUiSettingsServiceForRequest'); + decorations.request.getUiSettingsService(); + + const options = getUiSettingsServiceForRequest.firstCall.args[2]; + expect(options).to.have.property('readInterceptor'); + + const { readInterceptor } = options; + expect(readInterceptor).to.be.a('function'); + + status.green(); + expect(readInterceptor()).to.be(undefined); + + status.yellow(); + expect(readInterceptor()).to.eql({}); + + status.red(); + expect(readInterceptor()).to.eql({}); + + status.green(); + expect(readInterceptor()).to.eql(undefined); + }); + }); + + describe('server.uiSettings()', () => { + it('throws an error, links to pr', () => { + const { decorations } = setup(); + expect(decorations.server).to.have.property('uiSettings').a('function'); + expect(() => { + decorations.server.uiSettings(); + }).to.throwError('http://github.com'); + }); + }); +}); diff --git a/src/ui/ui_settings/__tests__/ui_settings_service.js b/src/ui/ui_settings/__tests__/ui_settings_service.js new file mode 100644 index 0000000000000..ed121e004bc69 --- /dev/null +++ b/src/ui/ui_settings/__tests__/ui_settings_service.js @@ -0,0 +1,376 @@ +import { isEqual } from 'lodash'; +import expect from 'expect.js'; +import { errors as esErrors } from 'elasticsearch'; +import Chance from 'chance'; + +import { UiSettingsService } from '../ui_settings_service'; + +import { createCallClusterStub } from './lib'; + +const INDEX = '.kibana'; +const TYPE = 'config'; +const ID = 'kibana-version'; +const chance = new Chance(); + +function setup(options = {}) { + const { + readInterceptor, + getDefaults, + defaults = {}, + esDocSource = {}, + callCluster = createCallClusterStub(INDEX, TYPE, ID, esDocSource) + } = options; + + const uiSettings = new UiSettingsService({ + index: INDEX, + type: TYPE, + id: ID, + getDefaults: getDefaults || (() => defaults), + readInterceptor, + callCluster, + }); + + return { + uiSettings, + assertGetQuery: callCluster.assertGetQuery, + assertUpdateQuery: callCluster.assertUpdateQuery, + }; +} + +describe('ui settings', () => { + describe('overview', () => { + it('has expected api surface', () => { + const { uiSettings } = setup(); + expect(uiSettings).to.have.property('get').a('function'); + expect(uiSettings).to.have.property('getAll').a('function'); + expect(uiSettings).to.have.property('getDefaults').a('function'); + expect(uiSettings).to.have.property('getRaw').a('function'); + expect(uiSettings).to.have.property('getUserProvided').a('function'); + expect(uiSettings).to.have.property('remove').a('function'); + expect(uiSettings).to.have.property('removeMany').a('function'); + expect(uiSettings).to.have.property('set').a('function'); + expect(uiSettings).to.have.property('setMany').a('function'); + }); + }); + + describe('#setMany()', () => { + it('returns a promise', () => { + const { uiSettings } = setup(); + expect(uiSettings.setMany({ a: 'b' })).to.be.a(Promise); + }); + + it('updates a single value in one operation', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.setMany({ one: 'value' }); + assertUpdateQuery({ one: 'value' }); + }); + + it('updates several values in one operation', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.setMany({ one: 'value', another: 'val' }); + assertUpdateQuery({ one: 'value', another: 'val' }); + }); + }); + + describe('#set()', () => { + it('returns a promise', () => { + const { uiSettings } = setup(); + expect(uiSettings.set('a', 'b')).to.be.a(Promise); + }); + + it('updates single values by (key, value)', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.set('one', 'value'); + assertUpdateQuery({ one: 'value' }); + }); + }); + + describe('#remove()', () => { + it('returns a promise', () => { + const { uiSettings } = setup(); + expect(uiSettings.remove('one')).to.be.a(Promise); + }); + + it('removes single values by key', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.remove('one'); + assertUpdateQuery({ one: null }); + }); + }); + + describe('#removeMany()', () => { + it('returns a promise', () => { + const { uiSettings } = setup(); + expect(uiSettings.removeMany(['one'])).to.be.a(Promise); + }); + + it('removes a single value', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.removeMany(['one']); + assertUpdateQuery({ one: null }); + }); + + it('updates several values in one operation', async () => { + const { uiSettings, assertUpdateQuery } = setup(); + await uiSettings.removeMany(['one', 'two', 'three']); + assertUpdateQuery({ one: null, two: null, three: null }); + }); + }); + + describe('#getDefaults()', () => { + it('returns a promise for the defaults', async () => { + const { uiSettings } = setup(); + const promise = uiSettings.getDefaults(); + expect(promise).to.be.a(Promise); + expect(await promise).to.eql({}); + }); + }); + + describe('getDefaults() argument', () => { + it('casts sync `getDefaults()` to promise', () => { + const getDefaults = () => ({ key: { value: chance.word() } }); + const { uiSettings } = setup({ getDefaults }); + expect(uiSettings.getDefaults()).to.be.a(Promise); + }); + + it('returns the defaults returned by getDefaults() argument', async () => { + const value = chance.word(); + const { uiSettings } = setup({ defaults: { key: { value } } }); + expect(await uiSettings.getDefaults()).to.eql({ + key: { value } + }); + }); + }); + + describe('#getUserProvided()', () => { + it('pulls user configuration from ES', async () => { + const { uiSettings, assertGetQuery } = setup(); + await uiSettings.getUserProvided(); + assertGetQuery(); + }); + + it('returns user configuration', async () => { + const esDocSource = { user: 'customized' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.getUserProvided(); + expect(isEqual(result, { + user: { userValue: 'customized' } + })).to.equal(true); + }); + + it('ignores null user configuration (because default values)', async () => { + const esDocSource = { user: 'customized', usingDefault: null, something: 'else' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.getUserProvided(); + expect(isEqual(result, { + user: { userValue: 'customized' }, something: { userValue: 'else' } + })).to.equal(true); + }); + + it('returns an empty object on 404 responses', async () => { + const { uiSettings } = setup({ + async callCluster() { + throw new esErrors[404](); + } + }); + + expect(await uiSettings.getUserProvided()).to.eql({}); + }); + + it('returns an empty object on 403 responses', async () => { + const { uiSettings } = setup({ + async callCluster() { + throw new esErrors[403](); + } + }); + + expect(await uiSettings.getUserProvided()).to.eql({}); + }); + + it('returns an empty object on NoConnections responses', async () => { + const { uiSettings } = setup({ + async callCluster() { + throw new esErrors.NoConnections(); + } + }); + + expect(await uiSettings.getUserProvided()).to.eql({}); + }); + + it('throws 401 errors', async () => { + const { uiSettings } = setup({ + async callCluster() { + throw new esErrors[401](); + } + }); + + try { + await uiSettings.getUserProvided(); + throw new Error('expect getUserProvided() to throw'); + } catch (err) { + expect(err).to.be.a(esErrors[401]); + } + }); + + it('throw when callCluster fails in some unexpected way', async () => { + const expectedUnexpectedError = new Error('unexpected'); + + const { uiSettings } = setup({ + async callCluster() { + throw expectedUnexpectedError; + } + }); + + try { + await uiSettings.getUserProvided(); + throw new Error('expect getUserProvided() to throw'); + } catch (err) { + expect(err).to.be(expectedUnexpectedError); + } + }); + }); + + describe('#getRaw()', () => { + it('pulls user configuration from ES', async () => { + const esDocSource = {}; + const { uiSettings, assertGetQuery } = setup({ esDocSource }); + await uiSettings.getRaw(); + assertGetQuery(); + }); + + it(`without user configuration it's equal to the defaults`, async () => { + const esDocSource = {}; + const defaults = { key: { value: chance.word() } }; + const { uiSettings } = setup({ esDocSource, defaults }); + const result = await uiSettings.getRaw(); + expect(result).to.eql(defaults); + }); + + it(`user configuration gets merged with defaults`, async () => { + const esDocSource = { foo: 'bar' }; + const defaults = { key: { value: chance.word() } }; + const { uiSettings } = setup({ esDocSource, defaults }); + const result = await uiSettings.getRaw(); + + expect(result).to.eql({ + foo: { + userValue: 'bar', + }, + key: { + value: defaults.key.value, + }, + }); + }); + }); + + describe('#getAll()', () => { + it('pulls user configuration from ES', async () => { + const esDocSource = {}; + const { uiSettings, assertGetQuery } = setup({ esDocSource }); + await uiSettings.getAll(); + assertGetQuery(); + }); + + it(`returns defaults when es doc is empty`, async () => { + const esDocSource = { }; + const defaults = { foo: { value: 'bar' } }; + const { uiSettings } = setup({ esDocSource, defaults }); + expect(await uiSettings.getAll()).to.eql({ + foo: 'bar' + }); + }); + + it(`merges user values, including ones without defaults, into key value pairs`, async () => { + const esDocSource = { + foo: 'user-override', + bar: 'user-provided', + }; + + const defaults = { + foo: { + value: 'default' + }, + }; + + const { uiSettings } = setup({ esDocSource, defaults }); + expect(await uiSettings.getAll()).to.eql({ + foo: 'user-override', + bar: 'user-provided', + }); + }); + }); + + describe('#get()', () => { + it('pulls user configuration from ES', async () => { + const esDocSource = {}; + const { uiSettings, assertGetQuery } = setup({ esDocSource }); + await uiSettings.get(); + assertGetQuery(); + }); + + it(`returns the promised value for a key`, async () => { + const esDocSource = {}; + const defaults = { dateFormat: { value: chance.word() } }; + const { uiSettings } = setup({ esDocSource, defaults }); + const result = await uiSettings.get('dateFormat'); + expect(result).to.equal(defaults.dateFormat.value); + }); + + it(`returns the user-configured value for a custom key`, async () => { + const esDocSource = { custom: 'value' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.get('custom'); + expect(result).to.equal('value'); + }); + + it(`returns the user-configured value for a modified key`, async () => { + const esDocSource = { dateFormat: 'YYYY-MM-DD' }; + const { uiSettings } = setup({ esDocSource }); + const result = await uiSettings.get('dateFormat'); + expect(result).to.equal('YYYY-MM-DD'); + }); + }); + + describe('readInterceptor() argument', () => { + describe('#getUserProvided()', () => { + it('returns a promise when interceptValue doesn\'t', () => { + const { uiSettings } = setup({ readInterceptor: () => ({}) }); + expect(uiSettings.getUserProvided()).to.be.a(Promise); + }); + + it('returns intercept values', async () => { + const { uiSettings } = setup({ + readInterceptor: () => ({ + foo: 'bar' + }) + }); + + expect(await uiSettings.getUserProvided()).to.eql({ + foo: { + userValue: 'bar' + } + }); + }); + }); + + describe('#getAll()', () => { + it('merges intercept value with defaults', async () => { + const { uiSettings } = setup({ + defaults: { + foo: { value: 'foo' }, + bar: { value: 'bar' }, + }, + + readInterceptor: () => ({ + foo: 'not foo' + }), + }); + + expect(await uiSettings.getAll()).to.eql({ + foo: 'not foo', + bar: 'bar' + }); + }); + }); + }); +}); diff --git a/src/ui/ui_settings/ui_exports_consumer.js b/src/ui/ui_settings/ui_exports_consumer.js new file mode 100644 index 0000000000000..74962c8845049 --- /dev/null +++ b/src/ui/ui_settings/ui_exports_consumer.js @@ -0,0 +1,43 @@ +/** + * The UiExports class accepts consumer objects that it consults while + * trying to consume all of the `uiExport` declarations provided by + * plugins. + * + * UiExportConsumer is instantiated and passed to UiExports, then for + * every `uiExport` declaration the `exportConsumer(type)` method is + * with the key of the declaration. If this consumer knows how to handle + * that key we return a function that will be called with the plugins + * and values of all declarations using that key. + * + * With this, the consumer merges all of the declarations into the + * _uiSettingDefaults map, ensuring that there are not collisions along + * the way. + * + * @class UiExportsConsumer + */ +export class UiExportsConsumer { + _uiSettingDefaults = {}; + + exportConsumer(type) { + switch (type) { + case 'uiSettingDefaults': + return (plugin, settingDefinitions) => { + Object.keys(settingDefinitions).forEach((key) => { + if (key in this._uiSettingDefaults) { + throw new Error(`uiSettingDefaults for key "${key}" are already defined`); + } + + this._uiSettingDefaults[key] = settingDefinitions[key]; + }); + }; + } + } + + /** + * Get the map of uiSettingNames to "default" specifications + * @return {Object} + */ + getUiSettingDefaults() { + return this._uiSettingDefaults; + } +} diff --git a/src/ui/ui_settings/ui_settings.js b/src/ui/ui_settings/ui_settings.js deleted file mode 100644 index e645cee68edc4..0000000000000 --- a/src/ui/ui_settings/ui_settings.js +++ /dev/null @@ -1,120 +0,0 @@ -import { defaultsDeep } from 'lodash'; -import Bluebird from 'bluebird'; - -import { getDefaultSettings } from './defaults'; - -function hydrateUserSettings(user) { - return Object.keys(user) - .map(key => ({ key, userValue: user[key] })) - .filter(({ userValue }) => userValue !== null) - .reduce((acc, { key, userValue }) => ({ ...acc, [key]: { userValue } }), {}); -} - -function assertRequest(req) { - if ( - !req || - typeof req !== 'object' || - typeof req.path !== 'string' || - !req.headers || - typeof req.headers !== 'object' - ) { - throw new TypeError('all uiSettings methods must be passed a hapi.Request object'); - } -} - -export class UiSettings { - constructor(server, status) { - this._server = server; - this._status = status; - } - - getDefaults() { - return getDefaultSettings(); - } - - // returns a Promise for the value of the requested setting - async get(req, key) { - assertRequest(req); - return this.getAll(req) - .then(all => all[key]); - } - - async getAll(req) { - assertRequest(req); - return this.getRaw(req) - .then(raw => Object.keys(raw) - .reduce((all, key) => { - const item = raw[key]; - const hasUserValue = 'userValue' in item; - all[key] = hasUserValue ? item.userValue : item.value; - return all; - }, {}) - ); - } - - async getRaw(req) { - assertRequest(req); - return this.getUserProvided(req) - .then(user => defaultsDeep(user, this.getDefaults())); - } - - async getUserProvided(req, { ignore401Errors = false } = {}) { - assertRequest(req); - const { callWithRequest, errors } = this._server.plugins.elasticsearch.getCluster('admin'); - - // If the ui settings status isn't green, we shouldn't be attempting to get - // user settings, since we can't be sure that all the necessary conditions - // (e.g. elasticsearch being available) are met. - if (this._status.state !== 'green') { - return hydrateUserSettings({}); - } - - const params = this._getClientSettings(); - const allowedErrors = [errors[404], errors[403], errors.NoConnections]; - if (ignore401Errors) allowedErrors.push(errors[401]); - - return Bluebird - .resolve(callWithRequest(req, 'get', params, { wrap401Errors: !ignore401Errors })) - .catch(...allowedErrors, () => ({})) - .then(resp => resp._source || {}) - .then(source => hydrateUserSettings(source)); - } - - async setMany(req, changes) { - assertRequest(req); - const { callWithRequest } = this._server.plugins.elasticsearch.getCluster('admin'); - const clientParams = { - ...this._getClientSettings(), - body: { doc: changes } - }; - return callWithRequest(req, 'update', clientParams) - .then(() => ({})); - } - - async set(req, key, value) { - assertRequest(req); - return this.setMany(req, { [key]: value }); - } - - async remove(req, key) { - assertRequest(req); - return this.set(req, key, null); - } - - async removeMany(req, keys) { - assertRequest(req); - const changes = {}; - keys.forEach(key => { - changes[key] = null; - }); - return this.setMany(req, changes); - } - - _getClientSettings() { - const config = this._server.config(); - const index = config.get('kibana.index'); - const id = config.get('pkg.version'); - const type = 'config'; - return { index, type, id }; - } -} diff --git a/src/ui/ui_settings/ui_settings_mixin.js b/src/ui/ui_settings/ui_settings_mixin.js index c8d37abbafc32..62b59db991542 100644 --- a/src/ui/ui_settings/ui_settings_mixin.js +++ b/src/ui/ui_settings/ui_settings_mixin.js @@ -1,17 +1,59 @@ -import { UiSettings } from './ui_settings'; +import { uiSettingsServiceFactory } from './ui_settings_service_factory'; +import { getUiSettingsServiceForRequest } from './ui_settings_service_for_request'; import { mirrorStatus } from './mirror_status'; +import { UiExportsConsumer } from './ui_exports_consumer'; export function uiSettingsMixin(kbnServer, server, config) { const status = kbnServer.status.create('ui settings'); + // reads the "uiSettingDefaults" from uiExports + const uiExportsConsumer = new UiExportsConsumer(); + kbnServer.uiExports.addConsumer(uiExportsConsumer); + if (!config.get('uiSettings.enabled')) { status.disabled('uiSettings.enabled config is set to `false`'); return; } - const uiSettings = new UiSettings(server, status); - server.decorate('server', 'uiSettings', () => uiSettings); + // Passed to the UiSettingsService. + // UiSettingsService calls the function before trying to read data from + // elasticsearch, giving us a chance to prevent it from happening. + // + // If the ui settings status isn't green we shouldn't be attempting to get + // user settings, since we can't be sure that all the necessary conditions + // (e.g. elasticsearch being available) are met. + const readInterceptor = () => { + if (status.state !== 'green') { + return {}; + } + }; + + const getDefaults = () => ( + uiExportsConsumer.getUiSettingDefaults() + ); + + // don't return, just let it happen when the plugins are ready kbnServer.ready().then(() => { mirrorStatus(status, kbnServer.status.getForPluginId('elasticsearch')); }); + + server.decorate('server', 'uiSettingsServiceFactory', (options = {}) => { + return uiSettingsServiceFactory(server, { + getDefaults, + ...options + }); + }); + + server.decorate('request', 'getUiSettingsService', function () { + return getUiSettingsServiceForRequest(server, this, { + getDefaults, + readInterceptor, + }); + }); + + server.decorate('server', 'uiSettings', () => { + throw new Error(` + server.uiSettings has been removed, see https://github.com/elastic/kibana/pull/12243. + `); + }); } diff --git a/src/ui/ui_settings/ui_settings_service.js b/src/ui/ui_settings/ui_settings_service.js new file mode 100644 index 0000000000000..57d0db8e9c96b --- /dev/null +++ b/src/ui/ui_settings/ui_settings_service.js @@ -0,0 +1,146 @@ +import { defaultsDeep, noop } from 'lodash'; +import { errors as esErrors } from 'elasticsearch'; + +function hydrateUserSettings(userSettings) { + return Object.keys(userSettings) + .map(key => ({ key, userValue: userSettings[key] })) + .filter(({ userValue }) => userValue !== null) + .reduce((acc, { key, userValue }) => ({ ...acc, [key]: { userValue } }), {}); +} + +/** + * Service that provides access to the UiSettings stored in elasticsearch. + * + * @class UiSettingsService + * @param {Object} options + * @property {string} options.index Elasticsearch index name where settings are stored + * @property {string} options.type type of ui settings Elasticsearch doc + * @property {string} options.id id of ui settings Elasticsearch doc + * @property {AsyncFunction} options.callCluster function that accepts a method name and + * param object which causes a request via some elasticsearch client + * @property {AsyncFunction} [options.readInterceptor] async function that is called when the + * UiSettingsService does a read() an has an oportunity to intercept the + * request and return an alternate `_source` value to use. + */ +export class UiSettingsService { + constructor(options) { + const { + index, + type, + id, + callCluster, + readInterceptor = noop, + // we use a function for getDefaults() so that defaults can be different in + // different scenarios, and so they can change over time + getDefaults = () => ({}), + } = options; + + this._callCluster = callCluster; + this._getDefaults = getDefaults; + this._readInterceptor = readInterceptor; + this._index = index; + this._type = type; + this._id = id; + } + + async getDefaults() { + return await this._getDefaults(); + } + + // returns a Promise for the value of the requested setting + async get(key) { + const all = await this.getAll(); + return all[key]; + } + + async getAll() { + const raw = await this.getRaw(); + + return Object.keys(raw) + .reduce((all, key) => { + const item = raw[key]; + const hasUserValue = 'userValue' in item; + all[key] = hasUserValue ? item.userValue : item.value; + return all; + }, {}); + } + + async getRaw() { + const userProvided = await this.getUserProvided(); + return defaultsDeep(userProvided, await this.getDefaults()); + } + + async getUserProvided(options) { + return hydrateUserSettings(await this._read(options)); + } + + async setMany(changes) { + await this._write(changes); + } + + async set(key, value) { + await this.setMany({ [key]: value }); + } + + async remove(key) { + await this.set(key, null); + } + + async removeMany(keys) { + const changes = {}; + keys.forEach(key => { + changes[key] = null; + }); + await this.setMany(changes); + } + + async _write(changes) { + await this._callCluster('update', { + index: this._index, + type: this._type, + id: this._id, + body: { + doc: changes + } + }); + } + + async _read(options = {}) { + const interceptValue = await this._readInterceptor(options); + if (interceptValue != null) { + return interceptValue; + } + + const { + ignore401Errors = false + } = options; + + const isIgnorableError = error => ( + error instanceof esErrors[404] || + error instanceof esErrors[403] || + error instanceof esErrors.NoConnections || + (ignore401Errors && error instanceof esErrors[401]) + ); + + try { + const clientParams = { + index: this._index, + type: this._type, + id: this._id, + }; + + const callOptions = { + wrap401Errors: !ignore401Errors + }; + + const resp = await this._callCluster('get', clientParams, callOptions); + return resp._source; + } catch (error) { + if (isIgnorableError(error)) { + return {}; + } + + throw error; + } + } +} diff --git a/src/ui/ui_settings/ui_settings_service_factory.js b/src/ui/ui_settings/ui_settings_service_factory.js new file mode 100644 index 0000000000000..c0b70223a3dda --- /dev/null +++ b/src/ui/ui_settings/ui_settings_service_factory.js @@ -0,0 +1,35 @@ +import { UiSettingsService } from './ui_settings_service'; + +/** + * Create an instance of UiSettingsService that will use the + * passed `callCluster` function to communicate with elasticsearch + * + * @param {Hapi.Server} server + * @param {Object} options + * @property {AsyncFunction} options.callCluster function that accepts a method name and + * param object which causes a request via some elasticsearch client + * @property {AsyncFunction} [options.getDefaults] async function that returns defaults/details about + * the uiSettings. + * @property {AsyncFunction} [options.readInterceptor] async function that is called when the + * UiSettingsService does a read() an has an oportunity to intercept the + * request and return an alternate `_source` value to use. + * @return {UiSettingsService} + */ +export function uiSettingsServiceFactory(server, options) { + const config = server.config(); + + const { + callCluster, + readInterceptor, + getDefaults, + } = options; + + return new UiSettingsService({ + index: config.get('kibana.index'), + type: 'config', + id: config.get('pkg.version'), + callCluster, + readInterceptor, + getDefaults, + }); +} diff --git a/src/ui/ui_settings/ui_settings_service_for_request.js b/src/ui/ui_settings/ui_settings_service_for_request.js new file mode 100644 index 0000000000000..d43657b6d8f42 --- /dev/null +++ b/src/ui/ui_settings/ui_settings_service_for_request.js @@ -0,0 +1,42 @@ +import { uiSettingsServiceFactory } from './ui_settings_service_factory'; + +const BY_REQUEST_CACHE = new WeakMap(); + +/** + * Get/create an instance of UiSettingsService bound to a specific request. + * Each call is cached (keyed on the request object itself) and subsequent + * requests will get the first UiSettingsService instance even if the `options` + * have changed. + * + * @param {Hapi.Server} server + * @param {Hapi.Request} request + * @param {Object} [options={}] + * @property {AsyncFunction} [options.getDefaults] async function that returns defaults/details about + * the uiSettings. + * @property {AsyncFunction} [options.readInterceptor] async function that is called when the + * UiSettingsService does a read() and has an oportunity to intercept the + * request and return an alternate `_source` value to use. + * @return {UiSettingsService} + */ +export function getUiSettingsServiceForRequest(server, request, options = {}) { + if (BY_REQUEST_CACHE.has(request)) { + return BY_REQUEST_CACHE.get(request); + } + + const { + readInterceptor, + getDefaults + } = options; + + const adminCluster = server.plugins.elasticsearch.getCluster('admin'); + const uiSettingsService = uiSettingsServiceFactory(server, { + readInterceptor, + getDefaults, + callCluster(...args) { + return adminCluster.callWithRequest(request, ...args); + } + }); + + BY_REQUEST_CACHE.set(request, uiSettingsService); + return uiSettingsService; +}